Add offline live spell checking (Hunspell via spellbook)

Spelling is now checked live as you type, fully offline, using the
pure-Rust `spellbook` crate to read Hunspell .aff/.dic dictionaries (no C
libhunspell — the binary stays self-contained; ldd unchanged).

- Canadian (en-CA, default) and British (en-GB) English dictionaries are
  compiled in; more languages are auto-discovered from system Hunspell
  folders and ~/.config/md-manuscript/dictionaries/. A "Spelling" row in
  the top bar toggles live checking and picks the dictionary.
- Misspellings are underlined in red; right-clicking a word opens a menu
  of suggested corrections. A results-panel and one-click fixes work too.
- Checks run on a background thread, debounced ~400ms after the last edit.
  Prose is extracted with pulldown-cmark so code spans, code blocks and
  link targets are skipped; ALL-CAPS initialisms are ignored.
- LanguageTool still layers on top: when its results are fresh they own
  the underlines (spelling + grammar); once you edit, the offline checker
  resumes. The editor underlines, issues panel and fix-apply logic are
  now shared between the two sources.

Bundled dictionaries are SCOWL-derived under a permissive license (kept
in dictionaries/<lang>/license). Adds spell-check unit tests (tokenizer,
code-block skipping, offset mapping, en-CA/en-GB spelling); 40 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs
This commit is contained in:
landon
2026-08-14 06:25:08 -05:00
parent 691d1a0515
commit 19dddf30c7
14 changed files with 101168 additions and 69 deletions
+428 -64
View File
@@ -5,12 +5,40 @@ use crate::order;
use eframe::egui;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Result of a background grammar check: the matches, or an error message.
type LtCheckResult = Result<Vec<crate::langtool::Match>, String>;
/// Channel payload from a background check: the text that was checked, paired
/// with its result (so the app can confirm the buffer hasn't changed since).
type LtCheckMsg = (String, LtCheckResult);
/// Channel payload from a background spell check: the checked text and the
/// spelling matches found in it.
type SpellCheckMsg = (String, Vec<crate::langtool::Match>);
/// Which check currently owns the editor's underlines and the issues panel.
#[derive(Clone, Copy, PartialEq, Eq)]
enum IssueSource {
/// Fresh LanguageTool results for the current buffer (spelling + grammar).
LanguageTool,
/// Live offline spell-check results (spelling only).
Spell,
/// Nothing current to show.
None,
}
/// An owned snapshot of one issue for the results panel, decoupled from `self`
/// so the panel can render without holding a borrow across its click handling.
struct IssueItem {
/// Index of this item in the source match list (for applying a fix).
idx: usize,
/// The offending text.
snippet: String,
message: String,
spelling: bool,
replacements: Vec<String>,
}
pub struct App {
config: Config,
@@ -86,6 +114,25 @@ pub struct App {
find_focus: Option<bool>,
/// Request the editor to scroll the active match into view next frame.
find_scroll: bool,
/// The loaded offline spell-check dictionary (shared with the worker thread).
spell_dict: Option<Arc<spellbook::Dictionary>>,
/// Dictionaries available to choose from (bundled + discovered on disk).
spell_dicts: Vec<crate::spell::DictEntry>,
/// Live spelling matches and the buffer text they were computed against.
spell_matches: Vec<crate::langtool::Match>,
spell_checked_text: String,
/// Set when the buffer changed and a re-check is owed.
spell_dirty: bool,
/// When the buffer was last edited, for debouncing the live check. `None`
/// means "check as soon as possible" (e.g. right after opening a file).
spell_last_edit: Option<Instant>,
/// In-flight background spell check, if any.
spell_rx: Option<std::sync::mpsc::Receiver<SpellCheckMsg>>,
/// One-line status for the spell checker (dictionary name, count, or error).
spell_status: String,
/// Index (into the currently displayed matches) of the word a right-click
/// suggestion menu is open for, if any.
spell_menu: Option<usize>,
}
impl App {
@@ -131,7 +178,17 @@ impl App {
find_needs_refresh: false,
find_focus: None,
find_scroll: false,
spell_dict: None,
spell_dicts: crate::spell::available(),
spell_matches: Vec::new(),
spell_checked_text: String::new(),
spell_dirty: true,
spell_last_edit: None,
spell_rx: None,
spell_status: String::new(),
spell_menu: None,
};
app.load_spell_dict();
app.open_workspace();
app
}
@@ -295,6 +352,12 @@ impl App {
self.find_matches.clear();
self.find_active = 0;
self.find_needs_refresh = true;
// Re-run the live spell check on the newly loaded buffer immediately.
self.spell_matches.clear();
self.spell_checked_text.clear();
self.spell_dirty = true;
self.spell_last_edit = None;
self.spell_menu = None;
if let Some(name) = self.files.get(idx) {
let path = self.path_for(name);
self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
@@ -643,33 +706,222 @@ impl App {
}
}
/// Apply replacement `rep_idx` of match `match_idx` to the buffer, then shift
/// the remaining matches so their highlights stay valid.
/// Apply a LanguageTool suggestion to the buffer, then shift the remaining
/// matches so their highlights stay valid.
fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) {
// Only safe while the buffer still matches what was checked.
if self.buffer != self.lt_checked_text {
return;
}
let Some(m) = self.lt_matches.get(match_idx).cloned() else {
return;
};
let Some(replacement) = m.replacements.get(rep_idx).cloned() else {
return;
};
if m.end > self.buffer.len() || !self.buffer.is_char_boundary(m.start) {
if splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) {
self.dirty = true;
self.find_needs_refresh = true;
self.lt_checked_text = self.buffer.clone();
self.lt_status = match self.lt_matches.len() {
0 => "No issues remaining".to_string(),
1 => "1 issue".to_string(),
n => format!("{n} issues"),
};
}
}
/// Apply a spell-check suggestion to the buffer, keeping the remaining
/// misspelling underlines aligned.
fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) {
if self.buffer != self.spell_checked_text {
return;
}
self.buffer.replace_range(m.start..m.end, &replacement);
self.dirty = true;
self.lt_checked_text = self.buffer.clone();
if splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) {
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"),
};
}
}
// The applied match overlaps its own region, so it is dropped here too.
remap_matches(&mut self.lt_matches, m.start, m.end, replacement.len());
self.lt_status = match self.lt_matches.len() {
0 => "No issues remaining".to_string(),
1 => "1 issue".to_string(),
n => format!("{n} issues"),
/// Apply suggestion `rep_idx` of the match at `match_idx` from whichever
/// source currently owns the issues (LanguageTool or the spell checker).
fn apply_current_fix(&mut self, match_idx: usize, rep_idx: usize) {
match self.issue_source() {
IssueSource::LanguageTool => self.apply_replacement(match_idx, rep_idx),
IssueSource::Spell => self.apply_spell_fix(match_idx, rep_idx),
IssueSource::None => {}
}
}
// ---- 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.
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.
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();
}
/// True when LanguageTool results describe the current buffer exactly (so
/// they, rather than the live spell checker, own the underlines).
fn lt_is_current(&self) -> bool {
!self.lt_checked_text.is_empty() && self.buffer == self.lt_checked_text
}
/// Which check currently owns the issues shown in the editor and panel.
fn issue_source(&self) -> IssueSource {
if self.lt_is_current() {
IssueSource::LanguageTool
} else if self.config.spell_check
&& self.spell_dict.is_some()
&& self.buffer == self.spell_checked_text
{
IssueSource::Spell
} else {
IssueSource::None
}
}
/// The matches currently owning the editor's underlines (may be empty).
fn current_matches(&self) -> &[crate::langtool::Match] {
match self.issue_source() {
IssueSource::LanguageTool => &self.lt_matches,
IssueSource::Spell => &self.spell_matches,
IssueSource::None => &[],
}
}
/// Index of the current match covering byte offset `byte`, if any.
fn match_index_at(&self, byte: usize) -> Option<usize> {
self.current_matches()
.iter()
.position(|m| byte >= m.start && byte < m.end)
}
/// An owned snapshot of the current issues for the results panel.
fn issue_items(&self) -> (IssueSource, Vec<IssueItem>) {
let source = self.issue_source();
let (matches, checked) = match source {
IssueSource::LanguageTool => (&self.lt_matches, self.lt_checked_text.as_str()),
IssueSource::Spell => (&self.spell_matches, self.spell_checked_text.as_str()),
IssueSource::None => return (source, Vec::new()),
};
let items = matches
.iter()
.enumerate()
.map(|(idx, m)| IssueItem {
idx,
snippet: checked.get(m.start..m.end).unwrap_or("").to_string(),
message: m.message.clone(),
spelling: m.spelling,
replacements: m.replacements.clone(),
})
.collect();
(source, items)
}
/// The status line for the spelling checker given a match count.
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.
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;
if self.spell_dict.is_some() {
self.spell_status = Self::spell_count_status(matches.len());
}
self.spell_matches = matches;
self.spell_checked_text = text;
}
}
/// 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.
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 (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, &text);
let _ = tx.send((text, matches));
ctx.request_repaint();
});
}
// ---- Find / replace ----------------------------------------------------
@@ -903,6 +1155,47 @@ impl App {
ui.label(egui::RichText::new(&self.lt_status).weak());
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.label("Spelling:").on_hover_text(
"Offline spell check (Hunspell dictionaries). Underlines misspellings \
as you type when LanguageTool results aren't current; right-click a \
word for suggestions.",
);
if ui
.checkbox(&mut self.config.spell_check, "Check as I type")
.on_hover_text("Underline misspellings live using the dictionary below")
.changed()
{
self.config.save();
if self.config.spell_check {
// Re-check the current buffer straight away.
self.spell_dirty = true;
self.spell_last_edit = None;
}
}
ui.label("Dictionary:");
let current = self.config.spell_language.clone();
let mut pick: Option<String> = None;
egui::ComboBox::from_id_salt("spell_dict")
.selected_text(current.clone())
.show_ui(ui, |ui| {
for d in &self.spell_dicts {
if ui
.selectable_label(current == d.id, &d.label)
.clicked()
{
pick = Some(d.id.clone());
}
}
});
if let Some(id) = pick {
self.set_spell_language(&id);
}
if !self.spell_status.is_empty() {
ui.label(egui::RichText::new(&self.spell_status).weak());
}
});
ui.add_space(4.0);
});
@@ -1229,24 +1522,23 @@ impl App {
self.show_settings = now_open;
}
/// Bottom panel listing grammar/spelling issues with one-click fixes.
/// Bottom panel listing the current grammar/spelling issues with one-click
/// fixes. Shows LanguageTool results when they're fresh, otherwise the live
/// spell-check results.
fn lt_panel(&mut self, ctx: &egui::Context) {
let (source, items) = self.issue_items();
egui::TopBottomPanel::bottom("ltpanel")
.resizable(true)
.default_height(190.0)
.show(ctx, |ui| {
let stale = self.buffer != self.lt_checked_text;
let (title, status) = match source {
IssueSource::LanguageTool => ("Grammar & spelling", self.lt_status.clone()),
_ => ("Spelling", self.spell_status.clone()),
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Grammar & spelling").strong());
if !self.lt_status.is_empty() {
ui.label(egui::RichText::new(&self.lt_status).weak());
}
if stale && !self.lt_matches.is_empty() {
ui.label(
egui::RichText::new("· edited since check — re-check to apply fixes")
.weak()
.italics(),
);
ui.label(egui::RichText::new(title).strong());
if !status.is_empty() {
ui.label(egui::RichText::new(status).weak());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Hide").clicked() {
@@ -1256,14 +1548,11 @@ impl App {
});
ui.separator();
if self.lt_matches.is_empty() {
if items.is_empty() {
let busy = self.lt_rx.is_some() || self.spell_rx.is_some();
ui.label(
egui::RichText::new(if self.lt_rx.is_some() {
"Checking…"
} else {
"No issues to show."
})
.weak(),
egui::RichText::new(if busy { "Checking…" } else { "No issues to show." })
.weak(),
);
return;
}
@@ -1272,34 +1561,31 @@ impl App {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
for (i, m) in self.lt_matches.iter().enumerate() {
for item in &items {
ui.horizontal_wrapped(|ui| {
let (dot, col) = if m.spelling {
("", egui::Color32::from_rgb(0xE0, 0x40, 0x40))
let col = if item.spelling {
egui::Color32::from_rgb(0xE0, 0x40, 0x40)
} else {
("", egui::Color32::from_rgb(0x3B, 0x82, 0xF6))
egui::Color32::from_rgb(0x3B, 0x82, 0xF6)
};
ui.label(egui::RichText::new(dot).color(col));
if let Some(snippet) = self.lt_checked_text.get(m.start..m.end) {
ui.label(egui::RichText::new("").color(col));
if !item.snippet.is_empty() {
ui.label(
egui::RichText::new(format!("{snippet}")).strong(),
egui::RichText::new(format!("{}", item.snippet)).strong(),
);
}
ui.label(&m.message);
ui.label(&item.message);
});
ui.horizontal_wrapped(|ui| {
ui.add_space(16.0);
if m.replacements.is_empty() {
if item.replacements.is_empty() {
ui.label(
egui::RichText::new("(no suggestion)").weak().italics(),
);
} else {
for (j, rep) in m.replacements.iter().take(8).enumerate() {
if ui
.add_enabled(!stale, egui::Button::new(rep).small())
.clicked()
{
apply = Some((i, j));
for (j, rep) in item.replacements.iter().enumerate() {
if ui.button(egui::RichText::new(rep).small()).clicked() {
apply = Some((item.idx, j));
}
}
}
@@ -1309,7 +1595,7 @@ impl App {
});
if let Some((i, j)) = apply {
self.apply_replacement(i, j);
self.apply_current_fix(i, j);
}
});
}
@@ -1435,16 +1721,13 @@ impl App {
// Editor text colour, brightened toward max contrast by the contrast slider.
let text_color = editor_text_color(ui.visuals(), self.config.editor_text_contrast);
// Underline grammar/spelling matches, but only while the buffer still
// equals the text they were computed against (edits invalidate offsets).
let ranges: Vec<(usize, usize, bool)> = if self.buffer == self.lt_checked_text {
self.lt_matches
.iter()
.map(|m| (m.start, m.end, m.spelling))
.collect()
} else {
Vec::new()
};
// Underline whichever check currently owns the buffer's issues
// (LanguageTool when fresh, otherwise the live spell checker).
let ranges: Vec<(usize, usize, bool)> = self
.current_matches()
.iter()
.map(|m| (m.start, m.end, m.spelling))
.collect();
// Search-match highlights (all matches shaded, the active one stronger).
let find_ranges: Vec<(usize, usize)> = if self.show_find {
@@ -1482,8 +1765,11 @@ impl App {
.show(ui);
if output.response.changed() {
self.dirty = true;
// The buffer changed, so any search matches are now stale.
// The buffer changed, so search matches and the live spell
// check both need refreshing (the latter debounced).
self.find_needs_refresh = true;
self.spell_dirty = true;
self.spell_last_edit = Some(Instant::now());
}
// Markdown formatting hotkeys, applied to the current selection
// while the editor is focused (Ctrl/Cmd + B / I / E / K, and
@@ -1524,6 +1810,55 @@ impl App {
}
self.find_scroll = false;
}
// Right-click a misspelling → a menu of suggested corrections.
if output.response.secondary_clicked() {
self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| {
let local = pos - output.galley_pos;
let cursor = output.galley.cursor_from_pos(local);
let byte = char_to_byte(&self.buffer, cursor.ccursor.index);
self.match_index_at(byte)
});
}
// Snapshot the target's suggestions (owned) so the menu closure
// doesn't borrow self; compute them on demand if the background
// check hadn't produced any for this word.
let menu: Option<(usize, Vec<String>)> = self.spell_menu.and_then(|i| {
let m = self.current_matches().get(i)?;
let mut reps = m.replacements.clone();
if reps.is_empty() {
if let (Some(dict), Some(word)) =
(&self.spell_dict, self.buffer.get(m.start..m.end))
{
reps = crate::spell::suggestions(dict, word);
}
}
Some((i, reps))
});
let mut chosen: Option<(usize, usize)> = None;
output.response.context_menu(|ui| {
match &menu {
Some((i, reps)) if !reps.is_empty() => {
ui.label(egui::RichText::new("Suggestions").strong());
for (j, rep) in reps.iter().enumerate() {
if ui.button(rep).clicked() {
chosen = Some((*i, j));
ui.close_menu();
}
}
}
Some(_) => {
ui.label(egui::RichText::new("No suggestions").weak());
}
None => {
ui.label(egui::RichText::new("No spelling issue here").weak());
}
}
});
if let Some((i, j)) = chosen {
self.apply_current_fix(i, j);
self.spell_menu = None;
}
});
}
@@ -1690,6 +2025,8 @@ impl eframe::App for App {
self.poll_lt();
self.poll_settings_test();
self.poll_spell();
self.maybe_start_spell_check(ctx);
self.menu_bar(ctx);
self.top_bar(ctx);
@@ -2180,6 +2517,33 @@ fn resolve_chapter_title(
.unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width))
}
/// Apply suggestion `rep_idx` of `matches[match_idx]` to `buffer` in place and
/// remap the remaining matches. Returns whether a replacement was made (false if
/// the indices are out of range or the match's bounds aren't valid boundaries).
fn splice_fix(
buffer: &mut String,
matches: &mut Vec<crate::langtool::Match>,
match_idx: usize,
rep_idx: usize,
) -> bool {
let Some(m) = matches.get(match_idx).cloned() else {
return false;
};
let Some(replacement) = m.replacements.get(rep_idx).cloned() else {
return false;
};
if m.end > buffer.len()
|| !buffer.is_char_boundary(m.start)
|| !buffer.is_char_boundary(m.end)
{
return false;
}
buffer.replace_range(m.start..m.end, &replacement);
// The applied match overlaps its own region, so it is dropped here too.
remap_matches(matches, m.start, m.end, replacement.len());
true
}
/// Re-position matches after the byte range `[s, e)` was replaced with `new_len`
/// bytes. Matches that overlapped the edited region (including the one that was
/// just applied) are dropped; matches entirely after it are shifted by the