mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Spelling: background squiggles, Add to Dictionary, per-run languages, compounds
This commit is contained in:
@@ -16,16 +16,17 @@ 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, word, lang, suggestions}] (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, 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!(
|
||||
"review.spellingChange",
|
||||
"Change Spelling",
|
||||
["Review", "Proofing"],
|
||||
None,
|
||||
"{shape, word, to, all?} → {changed} (replace the whole word in that shape's text, or in every shape with all)",
|
||||
"{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)",
|
||||
has_doc,
|
||||
spelling_change
|
||||
),
|
||||
cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary),
|
||||
cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info),
|
||||
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
|
||||
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
|
||||
@@ -226,37 +227,30 @@ fn check_lang(doc: &deckcraft_model::Presentation, p: &Value) -> String {
|
||||
found.unwrap_or_else(|| "en-US".into())
|
||||
}
|
||||
|
||||
/// The words of `text`: letters, digits, apostrophes inside a word and the dictionary's extras.
|
||||
fn words<'a>(text: &'a str, extra: &[char]) -> Vec<&'a str> {
|
||||
text.split(|c: char| !(c.is_alphanumeric() || c == '\'' || c == '’' || extra.contains(&c)))
|
||||
.map(|w| w.trim_matches(|c| c == '\'' || c == '’'))
|
||||
.filter(|w| !w.is_empty() && w.chars().any(char::is_alphabetic) && !w.chars().any(|c| c.is_ascii_digit()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let lang = check_lang(&st.doc, p);
|
||||
let default = check_lang(&st.doc, p);
|
||||
let ignore: Vec<String> =
|
||||
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 dict = crate::spell::dictionary(&lang);
|
||||
let extra: Vec<char> = dict.as_ref().map(|d| d.word_chars().to_vec()).unwrap_or_default();
|
||||
let mut dicts: std::collections::HashMap<String, Option<Arc<crate::spell::Dictionary>>> = Default::default();
|
||||
let mut out = vec![];
|
||||
for (i, sl) in st.doc.slides.iter().enumerate() {
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
let Some(t) = &sh.text else { return };
|
||||
let text = t.text();
|
||||
for w in words(&text, &extra) {
|
||||
if ignore.contains(&w.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let bad = match &dict {
|
||||
Some(d) => !d.check(w),
|
||||
None => COMMON.contains(&w.to_lowercase().as_str()),
|
||||
};
|
||||
if bad {
|
||||
let sug = dict.as_ref().map(|d| d.suggest(w, 5)).unwrap_or_default();
|
||||
out.push(json!({"slide": i, "shape": sh.id, "word": w, "lang": lang, "suggestions": sug}));
|
||||
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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -264,6 +258,12 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let word = str_param(p, "word").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.addToDictionary", "missing `word`"))?;
|
||||
crate::spell::add_user_word(word).map_err(|e| bad("review.addToDictionary", e.to_string()))?;
|
||||
Ok(json!({"word": word.trim(), "file": crate::spell::user_dictionary_path().map(|p| p.display().to_string())}))
|
||||
}
|
||||
|
||||
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
|
||||
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
|
||||
@@ -295,6 +295,29 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if shape.is_none() && !all {
|
||||
return Err(bad("review.spellingChange", "give `shape`, or `all: true`"));
|
||||
}
|
||||
// One exact occurrence (a squiggled word): paragraph `para`, from character `start`.
|
||||
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 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))
|
||||
.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"))?
|
||||
};
|
||||
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)) {
|
||||
replace_chars(pp, start, end, &to);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({"changed": 1}));
|
||||
}
|
||||
// Nothing to change makes no undo step.
|
||||
let mut any = false;
|
||||
let st = s.doc()?;
|
||||
@@ -339,6 +362,25 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
|
||||
/// Replace characters `start..end` of `p` with `to`, in the run where they start (later runs lose
|
||||
/// the rest of the range).
|
||||
fn replace_chars(p: &mut deckcraft_model::Paragraph, start: usize, end: usize, to: &str) {
|
||||
let mut at = 0;
|
||||
let mut placed = false;
|
||||
for r in &mut p.runs {
|
||||
let n = r.char_len();
|
||||
let (a, b) = (start.max(at), end.min(at + n));
|
||||
if a < b && r.kind == deckcraft_model::text::RunKind::Text {
|
||||
let byte = |c: usize| r.text.char_indices().nth(c).map_or(r.text.len(), |(i, _)| i);
|
||||
let (x, y) = (byte(a - at), byte(b - at));
|
||||
r.text.replace_range(x..y, if placed { "" } else { to });
|
||||
placed = true;
|
||||
}
|
||||
at += n;
|
||||
}
|
||||
p.runs.retain(|r| !(r.kind == deckcraft_model::text::RunKind::Text && r.text.is_empty()));
|
||||
}
|
||||
|
||||
fn walk_text_mut(shapes: &mut [deckcraft_model::Shape], depth: usize, f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
|
||||
@@ -485,6 +485,27 @@ fn commit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to,
|
||||
/// thick)` per line, thick under the part being converted. Empty without a composition or when
|
||||
/// the edited text isn't laid out on the slide (notes, table cells).
|
||||
/// Underlines (slide coordinates) under character ranges `(para, start, end)` of shape `id`'s
|
||||
/// text as laid out on the current slide: where spelling squiggles go.
|
||||
pub fn range_underlines(
|
||||
st: &DocState,
|
||||
id: deckcraft_model::ShapeId,
|
||||
ranges: &[(usize, usize, usize)],
|
||||
) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> {
|
||||
let Some(sh) = st.shape(id) else { return vec![] };
|
||||
let Some(l) = layout_for(st, &TextSel { shape: id, ..Default::default() }) else { return vec![] };
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, 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![] };
|
||||
|
||||
+268
-3
@@ -103,6 +103,13 @@ pub struct Dictionary {
|
||||
need_affix: Option<Flag>,
|
||||
no_suggest: Option<Flag>,
|
||||
keep_case: Option<Flag>,
|
||||
only_in_compound: Option<Flag>,
|
||||
compound: Option<Flag>,
|
||||
compound_begin: Option<Flag>,
|
||||
compound_middle: Option<Flag>,
|
||||
compound_end: Option<Flag>,
|
||||
compound_min: usize,
|
||||
compound_max: usize,
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8], latin1: bool) -> String {
|
||||
@@ -144,7 +151,7 @@ impl Dictionary {
|
||||
})
|
||||
})
|
||||
.unwrap_or(FlagMode::Char);
|
||||
let mut d = Dictionary::default();
|
||||
let mut d = Dictionary { compound_min: 3, ..Dictionary::default() };
|
||||
let mut lines = aff.lines().map(|l| l.split('#').next().unwrap_or("")).peekable();
|
||||
while let Some(l) = lines.next() {
|
||||
let w: Vec<&str> = l.split_whitespace().collect();
|
||||
@@ -155,6 +162,13 @@ impl Dictionary {
|
||||
["NEEDAFFIX", f, ..] | ["PSEUDOROOT", f, ..] => d.need_affix = one_flag(Some(f), mode),
|
||||
["NOSUGGEST", f, ..] => d.no_suggest = one_flag(Some(f), mode),
|
||||
["KEEPCASE", f, ..] => d.keep_case = one_flag(Some(f), mode),
|
||||
["ONLYINCOMPOUND", f, ..] => d.only_in_compound = one_flag(Some(f), mode),
|
||||
["COMPOUNDFLAG", f, ..] => d.compound = one_flag(Some(f), mode),
|
||||
["COMPOUNDBEGIN", f, ..] => d.compound_begin = one_flag(Some(f), mode),
|
||||
["COMPOUNDMIDDLE", f, ..] => d.compound_middle = one_flag(Some(f), mode),
|
||||
["COMPOUNDEND", f, ..] | ["COMPOUNDLAST", f, ..] => d.compound_end = one_flag(Some(f), mode),
|
||||
["COMPOUNDMIN", n, ..] => d.compound_min = n.parse::<usize>().unwrap_or(3).max(1),
|
||||
["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0),
|
||||
["REP", n] => {
|
||||
for _ in 0..n.parse::<usize>().unwrap_or(0) {
|
||||
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
|
||||
@@ -220,7 +234,12 @@ impl Dictionary {
|
||||
/// `word` as a root carrying all of `need` (and not forbidden).
|
||||
fn root(&self, word: &str, need: &[Flag], affixed: bool) -> bool {
|
||||
self.words.get(word).is_some_and(|hs| {
|
||||
hs.iter().any(|f| !self.has(f, self.forbidden) && (affixed || !self.has(f, self.need_affix)) && need.iter().all(|n| f.contains(n)))
|
||||
hs.iter().any(|f| {
|
||||
!self.has(f, self.forbidden)
|
||||
&& (affixed || !self.has(f, self.need_affix))
|
||||
&& !self.has(f, self.only_in_compound)
|
||||
&& need.iter().all(|n| f.contains(n))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -263,6 +282,53 @@ impl Dictionary {
|
||||
false
|
||||
}
|
||||
|
||||
/// A root (or, for the last part, a suffixed root) carrying `flag`, as a compound part.
|
||||
fn compound_part(&self, part: &[char], flag: Flag, last: bool) -> bool {
|
||||
let word: String = part.iter().collect();
|
||||
let ok = self.words.get(&word).is_some_and(|hs| hs.iter().any(|f| !self.has(f, self.forbidden) && f.contains(&flag)));
|
||||
ok || (last && self.suffixed_compound_end(part, flag))
|
||||
}
|
||||
|
||||
/// The last part of a compound with a suffix: the root must carry the compound `flag`.
|
||||
fn suffixed_compound_end(&self, w: &[char], flag: Flag) -> bool {
|
||||
self.sfx.iter().any(|s| {
|
||||
strip_end(w, &s.add, &s.strip).is_some_and(|stem| {
|
||||
s.cond_ok(&stem, false)
|
||||
&& self
|
||||
.words
|
||||
.get(&stem.iter().collect::<String>())
|
||||
.is_some_and(|hs| hs.iter().any(|f| !self.has(f, self.forbidden) && f.contains(&flag) && f.contains(&s.flag)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and
|
||||
/// end flags), each at least `COMPOUNDMIN` characters long.
|
||||
fn compounded(&self, w: &[char], index: usize) -> bool {
|
||||
if self.compound.is_none() && self.compound_end.is_none() {
|
||||
return false;
|
||||
}
|
||||
let min = self.compound_min.max(1);
|
||||
if index >= 8 || (self.compound_max > 0 && index + 1 >= self.compound_max) || w.len() < 2 * min {
|
||||
return false;
|
||||
}
|
||||
let role = if index == 0 { self.compound_begin } else { self.compound_middle };
|
||||
for i in min..=w.len() - min {
|
||||
let (head, rest) = w.split_at(i);
|
||||
let head_ok = [self.compound, role].into_iter().flatten().any(|f| self.compound_part(head, f, false));
|
||||
if !head_ok {
|
||||
continue;
|
||||
}
|
||||
if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) {
|
||||
return true;
|
||||
}
|
||||
if self.compounded(rest, index + 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn check_exact(&self, word: &str) -> bool {
|
||||
if self.forbidden_word(word) {
|
||||
return false;
|
||||
@@ -286,7 +352,7 @@ impl Dictionary {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
self.compounded(&w, 0)
|
||||
}
|
||||
|
||||
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
|
||||
@@ -494,6 +560,168 @@ pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Where Add to Dictionary keeps words: `user.dic` in DeckCraft's own dictionaries folder, one
|
||||
/// word per line (the Hunspell `.dic` format without a count).
|
||||
pub fn user_dictionary_path() -> Option<PathBuf> {
|
||||
if let Ok(p) = user_override().lock()
|
||||
&& let Some(p) = p.clone()
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||
let dir = if cfg!(target_os = "macos") {
|
||||
home.map(|h| h.join("Library/Application Support/DeckCraft/Dictionaries"))
|
||||
} else if cfg!(target_os = "windows") {
|
||||
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("DeckCraft").join("Dictionaries"))
|
||||
} else {
|
||||
std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home.map(|h| h.join(".local/share")))
|
||||
.map(|d| d.join("deckcraft/dictionaries"))
|
||||
};
|
||||
dir.map(|d| d.join("user.dic"))
|
||||
}
|
||||
|
||||
fn user_override() -> &'static Mutex<Option<PathBuf>> {
|
||||
static P: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
|
||||
P.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
struct UserWords {
|
||||
words: Option<HashSet<String>>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
fn user_state() -> &'static Mutex<UserWords> {
|
||||
static U: OnceLock<Mutex<UserWords>> = OnceLock::new();
|
||||
U.get_or_init(|| Mutex::new(UserWords { words: None, generation: 0 }))
|
||||
}
|
||||
|
||||
/// Keep the user dictionary at `path` instead (tests, or a portable install).
|
||||
pub fn set_user_dictionary_path(path: PathBuf) {
|
||||
if let Ok(mut p) = user_override().lock() {
|
||||
*p = Some(path);
|
||||
}
|
||||
if let Ok(mut u) = user_state().lock() {
|
||||
u.words = None;
|
||||
u.generation += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bumped whenever the user dictionary changes, so cached results can be dropped.
|
||||
pub fn user_generation() -> u64 {
|
||||
user_state().lock().map(|u| u.generation).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn with_user<T>(f: impl FnOnce(&HashSet<String>) -> T) -> Option<T> {
|
||||
let mut u = user_state().lock().ok()?;
|
||||
if u.words.is_none() {
|
||||
let text = user_dictionary_path().and_then(|p| std::fs::read_to_string(p).ok()).unwrap_or_default();
|
||||
u.words = Some(text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(str::to_string).collect());
|
||||
}
|
||||
u.words.as_ref().map(f)
|
||||
}
|
||||
|
||||
/// Add `word` to the user dictionary file (and to checks from now on).
|
||||
pub fn add_user_word(word: &str) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let word = word.trim();
|
||||
if word.is_empty() || word.contains(['\n', '\r']) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a word"));
|
||||
}
|
||||
if with_user(|u| u.contains(word)).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
||||
if let Some(d) = path.parent() {
|
||||
std::fs::create_dir_all(d)?;
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
writeln!(f, "{word}")?;
|
||||
if let Ok(mut u) = user_state().lock() {
|
||||
if let Some(w) = u.words.as_mut() {
|
||||
w.insert(word.to_string());
|
||||
}
|
||||
u.generation += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is `word` right: in the user dictionary (as written, or lower-case), else by `d`.
|
||||
pub fn is_correct(d: &Dictionary, word: &str) -> bool {
|
||||
with_user(|u| u.contains(word) || u.contains(&word.to_lowercase())).unwrap_or(false) || d.check(word)
|
||||
}
|
||||
|
||||
/// A word of a paragraph: its characters `start..end` (paragraph character offsets, as text
|
||||
/// positions count them) and the language of the run it starts in.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Word {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub text: String,
|
||||
pub lang: String,
|
||||
}
|
||||
|
||||
/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words
|
||||
/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked
|
||||
/// part by part.
|
||||
pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> {
|
||||
let mut chars: Vec<(char, usize)> = vec![];
|
||||
for (ri, r) in p.runs.iter().enumerate() {
|
||||
chars.extend(r.content().chars().map(|c| (c, ri)));
|
||||
}
|
||||
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string());
|
||||
let inside = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if !inside(chars[i].0) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < chars.len() && inside(chars[i].0) {
|
||||
i += 1;
|
||||
}
|
||||
let (mut a, mut b) = (start, i);
|
||||
while a < b && !chars[a].0.is_alphanumeric() {
|
||||
a += 1;
|
||||
}
|
||||
while b > a && !chars[b - 1].0.is_alphanumeric() {
|
||||
b -= 1;
|
||||
}
|
||||
let text: String = chars[a..b].iter().map(|c| c.0).collect();
|
||||
if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) {
|
||||
continue;
|
||||
}
|
||||
let mut k = a;
|
||||
for part in text.split('-') {
|
||||
let n = part.chars().count();
|
||||
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) });
|
||||
}
|
||||
k += n + 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The misspelt 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();
|
||||
paragraph_words(p, default)
|
||||
.into_iter()
|
||||
.filter(|w| !ignore.contains(&w.text.to_lowercase()))
|
||||
.filter(|w| {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself).
|
||||
pub fn install(lang: &str, d: Dictionary) {
|
||||
if let Ok(mut c) = cache().lock() {
|
||||
@@ -538,6 +766,43 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compounds_join_flagged_parts() {
|
||||
let aff = "COMPOUNDFLAG X\nCOMPOUNDMIN 3\nONLYINCOMPOUND O\nSFX S Y 1\nSFX S 0 en .\n";
|
||||
let d = Dictionary::parse(aff.as_bytes(), b"4\nhaus/X\ntuer/XS\nschluessel/X\nfugs/OX\n".as_slice());
|
||||
for w in ["haustuer", "haustueren", "haustuerschluessel", "haus", "hausfugstuer"] {
|
||||
assert!(d.check(w), "{w}");
|
||||
}
|
||||
for w in ["hausx", "fugs", "tuerhausz"] {
|
||||
assert!(!d.check(w), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_give_words_their_language() {
|
||||
use deckcraft_model::{Paragraph, Run, RunProps};
|
||||
let p = Paragraph {
|
||||
runs: vec![
|
||||
Run::with("Hello wor", RunProps { lang: Some("en-GB".into()), ..Default::default() }),
|
||||
Run::with("ld, Grüße well-known 3rd l'été", RunProps { lang: Some("de-DE".into()), ..Default::default() }),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let w = paragraph_words(&p, "en-US");
|
||||
let got: Vec<(&str, &str, usize)> = w.iter().map(|w| (w.text.as_str(), w.lang.as_str(), w.start)).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
("Hello", "en-GB", 0),
|
||||
("world", "en-GB", 6),
|
||||
("Grüße", "de-DE", 13),
|
||||
("well", "de-DE", 19),
|
||||
("known", "de-DE", 24),
|
||||
("l'été", "de-DE", 34)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
@@ -871,3 +871,41 @@ fn spelling_without_a_dictionary_falls_back_to_common_misspellings() {
|
||||
assert!(d["found"].is_null());
|
||||
assert_eq!(d["available"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spelling_checks_each_run_in_its_language_and_learns_words() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-userdic-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
crate::spell::set_user_dictionary_path(dir.join("user.dic"));
|
||||
crate::spell::install("xx-ONE", crate::spell::Dictionary::parse(b"", b"2\nhello\nworld\n"));
|
||||
crate::spell::install("xx-TWO", crate::spell::Dictionary::parse(b"", b"2\nhallo\nwelt\n"));
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "hello hallo deckcraft"})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
// The second word is in another language (as a PPTX run's `lang` gives it).
|
||||
s.edit(|doc, _| {
|
||||
let sh = deckcraft_model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[0]).shapes, id).unwrap();
|
||||
let p = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
let t = p.runs[0].text.clone();
|
||||
let props = p.runs[0].props.clone();
|
||||
p.runs = vec![
|
||||
deckcraft_model::Run::with(&t[..6], deckcraft_model::RunProps { lang: Some("xx-ONE".into()), ..props.clone() }),
|
||||
deckcraft_model::Run::with(&t[6..12], deckcraft_model::RunProps { lang: Some("xx-TWO".into()), ..props.clone() }),
|
||||
deckcraft_model::Run::with(&t[12..], deckcraft_model::RunProps { lang: Some("xx-ONE".into()), ..props }),
|
||||
];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let v = s.execute("review.spelling", &json!({})).unwrap();
|
||||
let words: Vec<_> = v
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| (w["word"].as_str().unwrap().to_string(), w["lang"].as_str().unwrap().to_string(), w["start"].as_u64().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(words, vec![("deckcraft".to_string(), "xx-ONE".to_string(), 12)]);
|
||||
s.execute("review.addToDictionary", &json!({"word": "deckcraft"})).unwrap();
|
||||
assert!(s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().is_empty());
|
||||
assert_eq!(std::fs::read_to_string(dir.join("user.dic")).unwrap(), "deckcraft\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ime_output(app, ui, xf);
|
||||
media_bar(app, ui, xf);
|
||||
gif_buttons(app, ui, xf, &gifs);
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let word = app.session.active().and_then(|st| app.squiggles.word_at(st, xf.to_slide(p), 4.0 / xf.scale.max(0.01) as f64));
|
||||
app.squiggles.menu_word = word;
|
||||
}
|
||||
context_menu(app, &resp);
|
||||
}
|
||||
|
||||
@@ -430,6 +436,14 @@ fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) {
|
||||
}
|
||||
|
||||
fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) {
|
||||
// Spelling squiggles under misspelt words (checked in the background).
|
||||
if let Some(st) = app.session.active() {
|
||||
let lines = app.squiggles.update(st, Some(ui.ctx()));
|
||||
let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length();
|
||||
for (a, b) in lines {
|
||||
crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale);
|
||||
}
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let sel_stroke = Stroke::new(1.0, t.selection);
|
||||
let editing = st.selection.text.clone();
|
||||
@@ -622,6 +636,32 @@ fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens)
|
||||
|
||||
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() {
|
||||
ui.label(egui::RichText::new("(no spelling suggestions)").weak());
|
||||
}
|
||||
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}));
|
||||
app.squiggles.menu_word = None;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if ui.button("Ignore All").clicked() {
|
||||
app.squiggles.ignore(&w.word);
|
||||
app.squiggles.menu_word = None;
|
||||
ui.close();
|
||||
}
|
||||
if 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());
|
||||
}
|
||||
app.squiggles.menu_word = None;
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] {
|
||||
if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() {
|
||||
|
||||
@@ -556,6 +556,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if ui.small_button("Ignore All").clicked() {
|
||||
add_ignore = Some(w.to_string());
|
||||
}
|
||||
if ui.small_button("Add to Dictionary").clicked()
|
||||
&& let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w}))
|
||||
{
|
||||
app.set_status(e.to_string());
|
||||
}
|
||||
});
|
||||
let sug: Vec<String> = item
|
||||
.get("suggestions")
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod ribbon;
|
||||
pub mod selpane;
|
||||
pub mod show;
|
||||
pub mod sorter;
|
||||
pub mod squiggles;
|
||||
pub mod status;
|
||||
pub mod textures;
|
||||
pub mod theme;
|
||||
@@ -183,6 +184,8 @@ pub struct SlideApp {
|
||||
pub ui: UiState,
|
||||
pub textures: textures::Textures,
|
||||
pub control_rx: Option<Receiver<ControlRequest>>,
|
||||
/// Spelling squiggles, checked on a worker thread.
|
||||
pub squiggles: squiggles::Squiggles,
|
||||
pub dialog: Option<dialogs::Dialog>,
|
||||
pub show: Option<show::Show>,
|
||||
/// Audio/video playback (editor control bar, slide show, `media.*` commands).
|
||||
@@ -238,6 +241,7 @@ impl SlideApp {
|
||||
ui: UiState::default(),
|
||||
textures: textures::Textures::default(),
|
||||
control_rx: None,
|
||||
squiggles: Default::default(),
|
||||
dialog: None,
|
||||
show: None,
|
||||
media: media::MediaHost::new(audio_out),
|
||||
|
||||
@@ -44,3 +44,72 @@ fn spelling_dialog_changes_and_ignores_words() {
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label_contains("No issues found").is_some());
|
||||
}
|
||||
|
||||
/// Misspelt words on the slide get squiggles from the background check; not the word being
|
||||
/// typed; the context menu's correction and Add to Dictionary clear them.
|
||||
#[test]
|
||||
fn squiggles_mark_misspelt_words_in_the_background() {
|
||||
let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n";
|
||||
let dic = "7\nthe/S\nslide/S\nquarter/S\nplan/S\nour\nfor\nreview\n";
|
||||
deckcraft_engine::spell::install("xx-SQ", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes()));
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-ui-userdic-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
deckcraft_engine::spell::set_user_dictionary_path(dir.join("user.dic"));
|
||||
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": "Our plna for teh quarter reveiw"}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
app.session.execute("format.size", &json!({"size": 40})).unwrap();
|
||||
// Mark the runs' language directly (what a PPTX run's `lang` gives).
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
app.session
|
||||
.edit(|doc, _| {
|
||||
let sl = std::sync::Arc::make_mut(&mut doc.slides[0]);
|
||||
let sh = deckcraft_model::find_shape_mut(&mut sl.shapes, sid).unwrap();
|
||||
for r in sh.text.as_mut().unwrap().paragraphs.iter_mut().flat_map(|p| p.runs.iter_mut()) {
|
||||
r.props.lang = Some("xx-SQ".into());
|
||||
}
|
||||
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(), 3, "{lines:?}");
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1100.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_SQUIGGLES"), h.render()) {
|
||||
let _ = img.save(path);
|
||||
}
|
||||
// The context menu's word under the first squiggle, and its correction.
|
||||
let st = h.state().session.active().unwrap().clone();
|
||||
let (a, _) = lines[0];
|
||||
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.addToDictionary", &json!({"word": "reveiw"})).unwrap();
|
||||
let st = h.state().session.active().unwrap().clone();
|
||||
assert_eq!(h.state_mut().squiggles.settle(&st).len(), 1);
|
||||
// Typing at the end of a misspelt word: no squiggle under it until the caret leaves.
|
||||
h.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
h.state_mut().session.execute("text.edit", &json!({})).unwrap();
|
||||
h.state_mut().session.execute("text.select", &json!({"anchor": [0, 16], "caret": [0, 16]})).unwrap();
|
||||
let st = h.state().session.active().unwrap().clone();
|
||||
assert_eq!(st.selection.text.as_ref().map(|t| t.caret), Some((0, 16)));
|
||||
assert_eq!(h.state_mut().squiggles.settle(&st).len(), 0, "the word being typed isn't marked");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Key {
|
||||
doc: usize,
|
||||
rev: u64,
|
||||
slide: usize,
|
||||
user: u64,
|
||||
}
|
||||
|
||||
struct Job {
|
||||
key: Key,
|
||||
default_lang: String,
|
||||
paras: Vec<(ShapeId, usize, Paragraph)>,
|
||||
repaint: Option<egui::Context>,
|
||||
}
|
||||
|
||||
/// Misspelt ranges for one key: `(shape, para, start, end)`.
|
||||
struct Done {
|
||||
key: Key,
|
||||
ranges: Vec<(ShapeId, usize, usize, usize)>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// 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 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.
|
||||
while let Ok(j) = rx.try_recv() {
|
||||
job = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_lang(st: &DocState) -> String {
|
||||
for sl in &st.doc.slides {
|
||||
let mut found = None;
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
if found.is_none()
|
||||
&& let Some(t) = &sh.text
|
||||
{
|
||||
found = t.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone());
|
||||
}
|
||||
});
|
||||
if let Some(l) = found {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
"en-US".into()
|
||||
}
|
||||
|
||||
impl Squiggles {
|
||||
fn start(&mut self) -> bool {
|
||||
if self.tx.is_some() {
|
||||
return true;
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Ask for a check of the current slide if it changed, take in finished checks, and return the
|
||||
/// underlines to draw (slide coordinates).
|
||||
pub fn update(&mut self, st: &DocState, repaint: Option<&egui::Context>) -> Vec<(Point, Point)> {
|
||||
if self.disabled || !self.start() {
|
||||
return vec![];
|
||||
}
|
||||
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) {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
if !fresh {
|
||||
return vec![];
|
||||
}
|
||||
// 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));
|
||||
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()))
|
||||
}
|
||||
})
|
||||
.map(|(_, a, b)| (*a, *b))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Skip `word` everywhere for the rest of the session.
|
||||
pub fn ignore(&mut self, word: &str) {
|
||||
self.ignored.push(word.to_lowercase());
|
||||
}
|
||||
|
||||
/// The squiggled word at slide point `p` (within `tol` points of its underline).
|
||||
pub fn word_at(&self, st: &DocState, p: Point, tol: f64) -> Option<MenuWord> {
|
||||
let ((shape, para, 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 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 { shape, para, start, word, suggestions })
|
||||
}
|
||||
|
||||
/// Wait (tests) until the check for the current state is in.
|
||||
pub fn settle(&mut self, st: &DocState) -> Vec<(Point, Point)> {
|
||||
for _ in 0..500 {
|
||||
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) {
|
||||
return v;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// A red wavy line from `a` to `b` (screen points).
|
||||
pub fn paint(painter: &egui::Painter, a: egui::Pos2, b: egui::Pos2, scale: f32) {
|
||||
let d = b - a;
|
||||
let len = d.length();
|
||||
if len < 1.0 {
|
||||
return;
|
||||
}
|
||||
let (u, n) = (d / len, egui::vec2(-d.y, d.x) / len);
|
||||
let step = (2.0 * scale).clamp(1.5, 3.0);
|
||||
let amp = step * 0.75;
|
||||
let mut pts = vec![];
|
||||
let mut t = 0.0;
|
||||
let mut up = false;
|
||||
while t <= len {
|
||||
pts.push(a + u * t + n * if up { -amp } else { amp });
|
||||
up = !up;
|
||||
t += step;
|
||||
}
|
||||
painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, egui::Color32::from_rgb(0xE0, 0x20, 0x20))));
|
||||
}
|
||||
+8
-5
@@ -176,12 +176,14 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
|
||||
|
||||
### Proofing: spelling, thesaurus, AutoCorrect
|
||||
- **Evidence:** `review.spelling` checks with Hunspell-format dictionaries the system already has
|
||||
(`crates/engine/src/spell.rs`: affixes, cross products, one level of continuation, case rules,
|
||||
suggestions; no compounding) and the Spelling dialog changes or ignores words, but none ship with
|
||||
DeckCraft, so without one installed (always on Windows and the web) only a short list of common
|
||||
misspellings is found. No red squiggles while typing, no per-run language, no Add to Dictionary.
|
||||
(`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.
|
||||
Thesaurus (#549) and AutoCorrect (#521) have open PRs.
|
||||
- **Estimate:** 4–8 h (bundle openly licensed dictionaries, squiggles, custom dictionary, compounding).
|
||||
- **Estimate:** 2–4 h (bundle openly licensed dictionaries; squiggles in notes and table cells).
|
||||
|
||||
### Equations
|
||||
- **Evidence:** checklist row missing; PR #55 (OMML editor, +10.5k lines) is open.
|
||||
@@ -260,6 +262,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
|
||||
Reference in New Issue
Block a user