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
This commit is contained in:
2026-08-24 20:16:28 -05:00
parent 043cc692ac
commit 66d3f9817e
6 changed files with 188 additions and 6 deletions
+17
View File
@@ -197,6 +197,7 @@ impl App {
.map(str::to_string)
});
let mut accept: Option<String> = None;
let mut dismiss: Option<usize> = None;
output.response.context_menu(|ui| {
match &menu {
Some((i, reps)) if !reps.is_empty() => {
@@ -215,6 +216,19 @@ impl App {
ui.label(egui::RichText::new("No spelling issue here").weak());
}
}
// Not every complaint is worth acting on — let a deliberate
// one be waved away instead of underlined for good.
if let Some((i, _)) = &menu {
ui.separator();
if ui
.button("✖ Dismiss this issue")
.on_hover_text("Stop flagging this in this file")
.clicked()
{
dismiss = Some(*i);
ui.close_menu();
}
}
// A character or place name is not a misspelling; let it be
// accepted for good rather than corrected every time.
if let Some(word) = &target_word {
@@ -234,6 +248,9 @@ impl App {
if let Some(word) = accept {
self.add_to_dictionary(&word);
}
if let Some(i) = dismiss {
self.dismiss_issue(i);
}
if let Some((i, j)) = chosen {
self.apply_current_fix(i, j);
self.spell_menu = None;
+147 -3
View File
@@ -95,13 +95,15 @@ impl App {
self.lt_rx = None;
match result {
Ok(matches) => {
self.lt_status = match matches.len() {
self.lt_matches = matches;
self.lt_checked_text = text;
// Complaints the user already waved away don't come back.
drop_dismissed(&mut self.lt_matches, &self.lt_checked_text, &self.dismissed);
self.lt_status = match self.lt_matches.len() {
0 => "No issues found".to_string(),
1 => "1 issue".to_string(),
n => format!("{n} issues"),
};
self.lt_matches = matches;
self.lt_checked_text = text;
}
Err(e) => {
self.lt_matches.clear();
@@ -154,6 +156,64 @@ impl App {
}
}
/// Hide the issue at `idx` and remember it, so neither checker raises the
/// same complaint about the same words again in this file.
pub(super) fn dismiss_issue(&mut self, idx: usize) {
let key = match self.issue_source() {
IssueSource::LanguageTool => {
dismiss_key(&self.lt_checked_text, self.lt_matches.get(idx))
}
IssueSource::Spell => {
dismiss_key(&self.spell_checked_text, self.spell_matches.get(idx))
}
IssueSource::None => None,
};
let Some(key) = key else { return };
self.dismissed.insert(key);
// Both lists are swept: the same words may be flagged by either checker.
drop_dismissed(&mut self.lt_matches, &self.lt_checked_text, &self.dismissed);
drop_dismissed(
&mut self.spell_matches,
&self.spell_checked_text,
&self.dismissed,
);
// The right-click menu's index refers to a list that just shifted.
self.spell_menu = None;
match self.issue_source() {
IssueSource::Spell => {
self.spell_status = Self::spell_count_status(self.spell_matches.len())
}
_ => {
self.lt_status = match self.lt_matches.len() {
0 => "No issues remaining".to_string(),
1 => "1 issue".to_string(),
n => format!("{n} issues"),
}
}
}
}
/// Take back every dismissal for this file. The spell checker rebuilds its
/// own list; LanguageTool's results can only come from the server, so they
/// are dropped and the user is pointed at the Check button.
pub(super) fn restore_dismissed(&mut self) {
if self.dismissed.is_empty() {
return;
}
self.dismissed.clear();
let had_lt = self.lt_is_current();
self.spell_dirty = true;
self.spell_last_edit = None;
self.spell_menu = None;
if had_lt {
self.clear_lt();
self.lt_status =
"Dismissals cleared — press ✓ Check to see grammar issues again".to_string();
} else {
self.spell_status = "Dismissals cleared".to_string();
}
}
/// Send the editor to the issue at `idx`, selecting it so it is obvious
/// which words the panel entry was talking about.
pub(super) fn jump_to_issue(&mut self, idx: usize) {
@@ -338,6 +398,7 @@ impl App {
/// spell-check results.
pub(super) fn lt_panel(&mut self, ctx: &egui::Context) {
let (source, items) = self.issue_items();
let mut restore = false;
egui::TopBottomPanel::bottom("ltpanel")
.resizable(true)
.default_height(190.0)
@@ -355,6 +416,14 @@ impl App {
if ui.button("Hide").clicked() {
self.show_lt_panel = false;
}
if !self.dismissed.is_empty()
&& ui
.button(format!("{} dismissed", self.dismissed.len()))
.on_hover_text("Bring the dismissed issues back")
.clicked()
{
restore = true;
}
});
});
ui.separator();
@@ -370,11 +439,19 @@ impl App {
let mut apply: Option<(usize, usize)> = None;
let mut jump: Option<usize> = None;
let mut dismiss: Option<usize> = None;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
for item in &items {
ui.horizontal_wrapped(|ui| {
if ui
.small_button("")
.on_hover_text("Dismiss — stop flagging this in this file")
.clicked()
{
dismiss = Some(item.idx);
}
let col = if item.spelling {
egui::Color32::from_rgb(0xE0, 0x40, 0x40)
} else {
@@ -416,12 +493,38 @@ impl App {
// A fix wins over a bare jump: it moves the editor there too.
if let Some((i, j)) = apply {
self.apply_current_fix(i, j);
} else if let Some(i) = dismiss {
self.dismiss_issue(i);
} else if let Some(i) = jump {
self.jump_to_issue(i);
}
});
if restore {
self.restore_dismissed();
}
}
}
/// How a dismissed issue is remembered: the offending text paired with the
/// explanation, so the same complaint about the same words is recognised after
/// a re-check (offsets, by then, have usually moved). `None` when the match's
/// range doesn't sit in `text`.
fn dismiss_key(text: &str, m: Option<&crate::langtool::Match>) -> Option<(String, String)> {
let m = m?;
Some((text.get(m.start..m.end)?.to_string(), m.message.clone()))
}
/// Drop every match in `matches` the user has dismissed.
pub(super) fn drop_dismissed(
matches: &mut Vec<crate::langtool::Match>,
text: &str,
dismissed: &HashSet<(String, String)>,
) {
if dismissed.is_empty() {
return;
}
matches.retain(|m| dismiss_key(text, Some(m)).is_none_or(|k| !dismissed.contains(&k)));
}
/// A panel label that behaves like a link to a place in the text: hand cursor,
/// hover hint, and `true` on the frame it is clicked.
@@ -515,6 +618,47 @@ mod tests {
assert_eq!((matches[0].start, matches[0].end), (12, 16));
}
#[test]
fn dismissed_matches_are_dropped_by_text_and_message() {
let text = "The team are ready. The crew are ready.";
let mut matches = vec![
crate::langtool::Match {
start: 9,
end: 12,
message: "Agreement".to_string(),
replacements: vec!["is".to_string()],
spelling: false,
},
crate::langtool::Match {
start: 29,
end: 32,
message: "Agreement".to_string(),
replacements: vec!["is".to_string()],
spelling: false,
},
crate::langtool::Match {
start: 4,
end: 8,
message: "Spelling".to_string(),
replacements: Vec::new(),
spelling: true,
},
];
let mut dismissed = HashSet::new();
dismissed.insert(("are".to_string(), "Agreement".to_string()));
drop_dismissed(&mut matches, text, &dismissed);
// Both "are" complaints go, wherever they sit; the other one stays.
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].message, "Spelling");
}
#[test]
fn drop_dismissed_keeps_everything_when_nothing_is_dismissed() {
let mut matches = vec![m(0, 3)];
drop_dismissed(&mut matches, "abc", &HashSet::new());
assert_eq!(matches.len(), 1);
}
#[test]
fn remap_drops_matches_overlapping_the_edit() {
// A match straddling the edit boundary is discarded, not mis-shifted.
+5
View File
@@ -301,6 +301,10 @@ pub struct App {
/// Index (into the currently displayed matches) of the word a right-click
/// suggestion menu is open for, if any.
spell_menu: Option<usize>,
/// Issues the user has waved away, as (offending text, message) pairs. Both
/// checkers drop matches keyed here, so a dismissed complaint stays hidden
/// when the file is checked again. Reset when a different file is opened.
dismissed: HashSet<(String, String)>,
/// Byte range in `buffer` the editor should select and scroll into view next
/// frame, set when an issue is clicked in the results panel.
issue_jump: Option<(usize, usize)>,
@@ -407,6 +411,7 @@ impl App {
diff_title: String::new(),
wordlist_input: String::new(),
spell_menu: None,
dismissed: HashSet::new(),
issue_jump: None,
show_mistral_settings: false,
show_template_settings: false,
+9 -3
View File
@@ -90,11 +90,17 @@ impl App {
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;
// 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());
}
}
}
+3
View File
@@ -34,6 +34,7 @@ impl App {
self.dirty = false;
self.pending_delete = false;
self.clear_lt();
self.dismissed.clear();
self.session_start_counts = self.snapshot_counts();
self.file_meta = self.snapshot_file_meta();
self.rebuild_field_names();
@@ -365,6 +366,8 @@ impl App {
self.spell_dirty = true;
self.spell_last_edit = None;
self.spell_menu = None;
// Dismissals are about this file's sentences, not the next one's.
self.dismissed.clear();
let Some(name) = self.files.get(idx).cloned() else {
return;
};
+7
View File
@@ -166,6 +166,13 @@ fn cheatsheet_body(ui: &mut egui::Ui) {
(grammar in blue, spelling in red) take over until you edit again, then the \
offline spell check resumes.",
);
note(
ui,
"Not every flag deserves a fix: press ✖ beside an issue in the panel (or \
right-click it in the text) to dismiss it, and neither checker will raise \
it again for that file. The panel header counts the dismissals and \
brings them back.",
);
section(ui, "Plot beats (Mistral)");
body(