66d3f9817e
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
386 lines
14 KiB
Rust
386 lines
14 KiB
Rust
//! 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(¤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
|
||
}
|