mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Spelling: notes right-click menu, Review > Language with Do Not Check Spelling, repeated words
This commit is contained in:
@@ -48,6 +48,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let c = color_param(p, "color");
|
||||
format_run(s, &move |r| r.highlight = c.clone())
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Value>| {
|
||||
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<usize> = 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();
|
||||
|
||||
@@ -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<usize>,
|
||||
}
|
||||
|
||||
/// 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<Wor
|
||||
if !part.is_empty()
|
||||
&& let Some(c) = chars.get(k)
|
||||
{
|
||||
out.push(Word { start: k, end: k + n, text: part.to_string(), lang: lang_of(c.1) });
|
||||
if p.runs.get(c.1).is_some_and(|r| r.props.no_proof == Some(true)) {
|
||||
// Marked "do not check spelling".
|
||||
} else {
|
||||
out.push(Word { start: k, end: k + n, text: part.to_string(), lang: lang_of(c.1), repeated: None });
|
||||
}
|
||||
}
|
||||
k += n + 1;
|
||||
}
|
||||
@@ -708,7 +715,27 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
|
||||
out
|
||||
}
|
||||
|
||||
/// The misspelt words of `p`, each checked in its own language (languages with no dictionary
|
||||
/// Words that repeat the word just before them (`the the`), with only spaces between, as
|
||||
/// PowerPoint flags them. Hyphenated parts and different languages don't count.
|
||||
pub fn repeated(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> {
|
||||
let words = paragraph_words(p, default);
|
||||
let chars: Vec<char> = 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<Word> {
|
||||
let mut dicts: HashMap<String, Option<Arc<Dictionary>>> = 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::<Vec<_>>()
|
||||
.into_iter()
|
||||
.fold(vec![], |mut v: Vec<Word>, 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<usize>)> = 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<String> = 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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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::<String>())
|
||||
.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 {
|
||||
|
||||
@@ -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::<String>())
|
||||
});
|
||||
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<deckcraft_model::RunProps> {
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -195,6 +195,8 @@ pub struct Squiggles {
|
||||
pub disabled: bool,
|
||||
/// The misspelt word a context menu is open on.
|
||||
pub menu_word: Option<MenuWord>,
|
||||
/// The same for the notes pane (`Tag::Notes`, `start` in the line).
|
||||
pub notes_menu: Option<MenuWord>,
|
||||
/// Words skipped with Ignore All this session (lower-case).
|
||||
ignored: Vec<String>,
|
||||
}
|
||||
@@ -206,6 +208,55 @@ pub struct MenuWord {
|
||||
pub start: usize,
|
||||
pub word: String,
|
||||
pub suggestions: Vec<String>,
|
||||
/// A repeated word: Delete Repeated Word removes characters `from..start + word`.
|
||||
pub delete_from: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<MenuWord> {
|
||||
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.
|
||||
|
||||
@@ -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::<String>();
|
||||
crate::squiggles::menu_word(
|
||||
&deckcraft_model::Paragraph::new(&line),
|
||||
&lang,
|
||||
crate::squiggles::Tag::Notes { start: line_start },
|
||||
a - line_start,
|
||||
)
|
||||
});
|
||||
}
|
||||
let mut changed: Option<String> = 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<char> = 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::<Vec<_>>().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}));
|
||||
|
||||
+6
-3
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user