Files
md-manuscript/src/app/spelling.rs
T
landon 66d3f9817e Let a spelling or grammar issue be dismissed
Not every flag deserves a fix, so give the checkers a way to be told no:

- ✖ beside an issue in the panel, and a matching entry in the editor's
  right-click menu, wave that issue away.
- A dismissal is remembered as the offending text paired with the message
  rather than a byte range, since offsets move as soon as you type. Both
  checkers filter their fresh results against it, so a dismissed complaint
  stays gone across re-checks and repeats of the same phrase in the file.
- The panel header counts the dismissals and ↩ takes them back. The offline
  spell check then rebuilds its own list; LanguageTool's can only come from
  the server, so those are dropped with a nudge towards ✓ Check.

Dismissals belong to the open file and the session — they are cleared when
another file or workspace is opened, and never written to disk. A name or
invented term still belongs in the word list, which persists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CVsxwa6YukFS2YFUY8W2j
2026-08-24 20:16:28 -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
/// `06-First Draft` and the sheets are its sibling `03-Characters`.
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
}