Files
md-manuscript/src/app/spelling.rs
T
landon 114f4ff697 docs: Update all references from old 05-Plot/06-First Draft to merged 05-First Draft
- Update project_template test to verify old directories are NOT created
- Update hover text and comments in project.rs
- Update comments and code in file_list.rs
- Update comment in spelling.rs
- Add test assertions to verify 05-Plot and 06-First Draft don't exist in new projects

This ensures all code references match the merged directory structure where
planning and draft content are combined in 05-First Draft with YAML frontmatter.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 15:34:36 -05:00

386 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
/// `05-First Draft` and the sheets are its sibling `03-Story-Bible`.
pub(super) fn characters_dir(&self) -> Option<PathBuf> {
/// 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<usize, String> {
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<String> = 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<PathBuf> {
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(&current) 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
}