mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Spelling: squiggles in table cells and notes; UI-thread checking on the web
This commit is contained in:
@@ -16,13 +16,13 @@ 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, 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
|
||||
),
|
||||
@@ -234,26 +234,48 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String, Option<Arc<crate::spell::Dictionary>>> = 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<Value>| {
|
||||
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))
|
||||
}
|
||||
@@ -299,11 +321,16 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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::<String>() == word)
|
||||
});
|
||||
found.ok_or_else(|| bad("review.spellingChange", "the word isn't there"))?
|
||||
@@ -311,7 +338,12 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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(())
|
||||
|
||||
@@ -506,6 +506,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
|
||||
}
|
||||
|
||||
pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
|
||||
let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] };
|
||||
let Some(sh) = st.shape(t.shape) else { return vec![] };
|
||||
|
||||
@@ -51,6 +51,7 @@ pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
|
||||
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).
|
||||
|
||||
@@ -224,7 +224,7 @@ fn edges(sizes: &[f64]) -> Vec<f64> {
|
||||
}
|
||||
|
||||
/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
|
||||
pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
|
||||
pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
|
||||
let lk = look(rctx, t, r, c);
|
||||
let mut body = cell.text.clone();
|
||||
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
|
||||
|
||||
@@ -643,7 +643,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();
|
||||
}
|
||||
|
||||
@@ -575,9 +575,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<String> = notes
|
||||
.split('\n')
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
if i != pi {
|
||||
return l.to_string();
|
||||
}
|
||||
let c: Vec<char> = l.chars().collect();
|
||||
let n = w.chars().count();
|
||||
if c.get(st..st + n).is_some_and(|x| x.iter().collect::<String>() == w) {
|
||||
c[..st].iter().chain(to.chars().collect::<Vec<_>>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
+328
-131
@@ -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<egui::Context>,
|
||||
}
|
||||
|
||||
/// 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::time::Instant> = 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<Sender<Job>>,
|
||||
rx: Option<Receiver<Done>>,
|
||||
/// The check in flight, if any.
|
||||
pending: Option<Key>,
|
||||
done: Option<Done>,
|
||||
/// 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<MenuWord>,
|
||||
/// Words skipped with Ignore All this session (lower-case).
|
||||
ignored: Vec<String>,
|
||||
struct Checker {
|
||||
cache: HashMap<u64, Vec<(usize, usize)>>,
|
||||
user: Option<u64>,
|
||||
/// 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<String>,
|
||||
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<egui::Context>)> {
|
||||
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<Job>, tx: Sender<Done>) {
|
||||
// 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<Sender<Job>>,
|
||||
rx: Option<Receiver<Done>>,
|
||||
/// No worker thread (the web build, or forced for tests): check on the UI thread.
|
||||
inline: Option<Checker>,
|
||||
/// Check on the UI thread even where threads exist (tests of the web path).
|
||||
pub force_inline: bool,
|
||||
pending: [Option<Key>; 2],
|
||||
done: [Option<Done>; 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<Key>,
|
||||
/// Off (tests or the setting).
|
||||
pub disabled: bool,
|
||||
/// The misspelt word a context menu is open on.
|
||||
pub menu_word: Option<MenuWord>,
|
||||
/// Words skipped with Ignore All this session (lower-case).
|
||||
ignored: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<ShapeId, Vec<(usize, usize, usize)>> = 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::<String>()));
|
||||
!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<usize>, 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<char> = 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::<String>()).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<MenuWord> {
|
||||
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<Paragraph> {
|
||||
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<String> {
|
||||
Some(paragraph_of(st, tag)?.text().chars().skip(a).take(b - a).collect())
|
||||
}
|
||||
|
||||
/// A red wavy line from `a` to `b` (screen points).
|
||||
|
||||
@@ -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<String> {
|
||||
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 };
|
||||
@@ -145,15 +150,27 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
app.notes_buf.2 = current.clone();
|
||||
}
|
||||
let mut buf = app.notes_buf.2.clone();
|
||||
let r = 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)),
|
||||
);
|
||||
let out = 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);
|
||||
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}));
|
||||
|
||||
+6
-4
@@ -179,11 +179,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.
|
||||
@@ -262,6 +263,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
|
||||
Reference in New Issue
Block a user