//! Offline spell checking: loading a bundled or system Hunspell dictionary, //! running checks in the background, and applying a correction. use super::*; impl App { /// Apply a spell-check suggestion to the buffer, keeping the remaining /// misspelling underlines aligned. pub(super) fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) -> bool { if self.buffer != self.spell_checked_text { return false; } if !splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) { return false; } self.dirty = true; self.find_needs_refresh = true; // Keep these matches valid without forcing a full re-check. self.spell_checked_text = self.buffer.clone(); self.spell_status = match self.spell_matches.len() { 0 => "No spelling issues".to_string(), 1 => "1 spelling issue".to_string(), n => format!("{n} spelling issues"), }; true } // ---- Spell check (offline) --------------------------------------------- /// (Re)load the dictionary named by `config.spell_language`, falling back to /// the default and then to whatever is available. Resets the live results so /// the buffer is re-checked against the new dictionary. pub(super) fn load_spell_dict(&mut self) { let want = self.config.spell_language.clone(); let entry = self .spell_dicts .iter() .find(|d| d.id == want) .or_else(|| { self.spell_dicts .iter() .find(|d| d.id == crate::spell::DEFAULT_LANGUAGE) }) .or_else(|| self.spell_dicts.first()) .cloned(); match entry { Some(e) => match e.load() { Ok(dict) => { self.spell_dict = Some(Arc::new(dict)); self.spell_status = format!("Dictionary: {}", e.id); } Err(err) => { self.spell_dict = None; self.spell_status = err; } }, None => { self.spell_dict = None; self.spell_status = "No spelling dictionary available".to_string(); } } self.spell_matches.clear(); self.spell_checked_text.clear(); self.spell_dirty = true; self.spell_last_edit = None; } /// Switch the active dictionary and persist the choice. pub(super) fn set_spell_language(&mut self, id: &str) { if self.config.spell_language == id { return; } self.config.spell_language = id.to_string(); self.config.save(); self.load_spell_dict(); } /// The status line for the spelling checker given a match count. pub(super) fn spell_count_status(n: usize) -> String { match n { 0 => "No spelling issues".to_string(), 1 => "1 spelling issue".to_string(), n => format!("{n} spelling issues"), } } /// Pick up a finished background spell check. pub(super) fn poll_spell(&mut self) { let received = self.spell_rx.as_ref().and_then(|rx| rx.try_recv().ok()); if let Some((text, matches)) = received { self.spell_rx = None; self.spell_matches = matches; self.spell_checked_text = text; // Words the user waved away stay quiet across re-checks. drop_dismissed( &mut self.spell_matches, &self.spell_checked_text, &self.dismissed, ); if self.spell_dict.is_some() { self.spell_status = Self::spell_count_status(self.spell_matches.len()); } } } /// Start a background spell check of the current buffer if one is due: /// enabled, a dictionary loaded, a file open, the buffer changed, no check /// already running, and the debounce interval elapsed since the last edit. pub(super) fn maybe_start_spell_check(&mut self, ctx: &egui::Context) { if !self.config.spell_check || self.selected.is_none() || !self.spell_dirty { return; } // LanguageTool results already cover this exact buffer (spelling too), so // don't spend effort on a spell pass that wouldn't be shown. if self.lt_is_current() { return; } let Some(dict) = self.spell_dict.clone() else { return; }; if self.spell_rx.is_some() { return; } // Debounce so we don't re-check on every keystroke. const DEBOUNCE: Duration = Duration::from_millis(400); if let Some(edited) = self.spell_last_edit { let elapsed = edited.elapsed(); if elapsed < DEBOUNCE { ctx.request_repaint_after(DEBOUNCE - elapsed); return; } } self.spell_dirty = false; let text = self.buffer.clone(); let extra = self.wordlist.clone(); let (tx, rx) = std::sync::mpsc::channel(); self.spell_rx = Some(rx); let ctx = ctx.clone(); std::thread::spawn(move || { let matches = crate::spell::check(&dict, &extra, &text); let _ = tx.send((text, matches)); ctx.request_repaint(); }); } // ---- The project word list --------------------------------------------- /// Accept `word` from now on: add it to the workspace's word list, persist /// it, and drop the underlines it was causing without waiting for a /// re-check. pub(super) fn add_to_dictionary(&mut self, word: &str) { let word = word.trim().to_string(); if word.is_empty() { return; } if !self.wordlist.insert(&word) { self.spell_status = format!("“{word}” is already in the word list"); return; } self.persist_wordlist(); self.drop_accepted_matches(); self.spell_status = format!("Added “{word}” — {}", Self::spell_count_status( self.spell_matches.len() )); } /// Stop accepting `word`, so it is flagged again. pub(super) fn remove_from_dictionary(&mut self, word: &str) { if self.wordlist.remove(word) { self.persist_wordlist(); // The word has to be found again, which needs a full pass. self.spell_dirty = true; self.spell_last_edit = None; self.spell_status = format!("Removed “{word}” from the word list"); } } pub(super) fn persist_wordlist(&mut self) { if let Err(e) = crate::spell::write_wordlist(self.workspace(), &self.wordlist) { self.status = format!("Could not save the word list: {e}"); } } /// Clear the matches the word list now accepts, so an added word stops being /// underlined immediately rather than after the next debounce. fn drop_accepted_matches(&mut self) { let text = self.spell_checked_text.clone(); let list = self.wordlist.clone(); self.spell_matches.retain(|m| { text.get(m.start..m.end) .is_none_or(|w| !list.accepts(w)) }); self.spell_menu = None; } /// The project's character folder, looked for beside the workspace and then /// up through its ancestors — in a snowflake project the workspace is /// `06-First Draft` and the sheets are its sibling `03-Characters`. pub(super) fn characters_dir(&self) -> Option { /// How far up to look before giving up; deep enough for the layout, /// shallow enough not to wander into the home directory. const MAX_UP: usize = 3; let mut dir = Some(self.workspace()); for _ in 0..=MAX_UP { let current = dir?; if let Ok(read) = std::fs::read_dir(current) { for entry in read.flatten() { let name = entry.file_name(); let Some(name) = name.to_str() else { continue }; if crate::spell::is_characters_dir(name) && entry.path().is_dir() { return Some(entry.path()); } } } dir = current.parent(); } None } /// Add every name found in the project's character sheets to the word list. /// Returns how many were new, or an error message. pub(super) fn harvest_character_names(&mut self) -> Result { let dir = self .characters_dir() .ok_or_else(|| "No character folder found near this workspace".to_string())?; let mut added = 0usize; let mut sheets = 0usize; for path in markdown_files_under(&dir) { let Ok(text) = std::fs::read_to_string(&path) else { continue; }; sheets += 1; for name in crate::spell::names_from_sheet(&text) { if self.wordlist.insert(&name) { added += 1; } } } if sheets == 0 { return Err(format!("No character sheets in {}", dir.display())); } if added > 0 { self.persist_wordlist(); self.drop_accepted_matches(); } Ok(added) } /// The workspace's word list: what the checker accepts on top of the /// dictionary, where it came from, and how to take a word back out. pub(super) fn wordlist_window(&mut self, ctx: &egui::Context) { let mut open = self.show_wordlist; let mut close = false; let mut remove: Option = None; let mut harvest = false; egui::Window::new("Word list") .open(&mut open) .resizable(true) .collapsible(false) .default_width(360.0) .show(ctx, |ui| { ui.label( egui::RichText::new( "Names and invented terms the spell checker accepts. Saved in \ the workspace as wordlist.json, so it is committed with the \ manuscript.", ) .small() .weak(), ); ui.add_space(4.0); ui.horizontal(|ui| { ui.add( egui::TextEdit::singleline(&mut self.wordlist_input) .hint_text("add a word") .desired_width(200.0), ); if ui.button("+ Add").clicked() { let word = self.wordlist_input.trim().to_string(); if !word.is_empty() { self.add_to_dictionary(&word); self.wordlist_input.clear(); } } }); let found = self.characters_dir(); ui.horizontal(|ui| { if ui .add_enabled(found.is_some(), egui::Button::new("👤 Add character names")) .on_hover_text(match &found { Some(dir) => format!("Read the sheets in {}", dir.display()), None => "No character folder found near this workspace".to_string(), }) .clicked() { harvest = true; } }); ui.separator(); ui.label( egui::RichText::new(format!("{} word(s)", self.wordlist.len())).strong(), ); egui::ScrollArea::vertical() .auto_shrink([false, true]) .max_height(260.0) .show(ui, |ui| { if self.wordlist.is_empty() { ui.label( egui::RichText::new( "Nothing yet — right-click a underlined word in the \ editor to add it.", ) .weak(), ); } for word in self.wordlist.words() { ui.horizontal(|ui| { if ui .small_button("✖") .on_hover_text("Flag this word again") .clicked() { remove = Some(word.clone()); } ui.label(word); }); } }); ui.separator(); if ui.button("Close").clicked() { close = true; } }); if let Some(word) = remove { self.remove_from_dictionary(&word); } if harvest { self.spell_status = match self.harvest_character_names() { Ok(0) => "No new names in the character sheets".to_string(), Ok(n) => format!("Added {n} name(s) from the character sheets"), Err(e) => format!("✖ {e}"), }; } self.show_wordlist = open && !close; } } /// Every `*.md` file at or below `dir`, one level of nesting at a time. Project /// folders group their files into subfolders — character sheets into /// `main_characters`, the outline into `scene_breakdown` — so a flat read of the /// top level would miss all of them. pub(super) fn markdown_files_under(dir: &Path) -> Vec { let mut out = Vec::new(); let mut stack = vec![dir.to_path_buf()]; // A guard against a symlink pointing back up the tree. let mut budget = 512usize; while let Some(current) = stack.pop() { let Ok(read) = std::fs::read_dir(¤t) else { continue; }; for entry in read.flatten() { if budget == 0 { return out; } budget -= 1; let path = entry.path(); if path.is_dir() { stack.push(path); } else if path .extension() .is_some_and(|e| e.eq_ignore_ascii_case("md")) { out.push(path); } } } out.sort(); out }