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:
+428
-64
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user