From 66d3f9817e8983f500d1fe5d5af7e4ebddbe20c8 Mon Sep 17 00:00:00 2001 From: landon Date: Mon, 24 Aug 2026 20:16:28 -0500 Subject: [PATCH] Let a spelling or grammar issue be dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_011CVsxwa6YukFS2YFUY8W2j --- src/app/editor.rs | 17 +++++ src/app/grammar.rs | 150 ++++++++++++++++++++++++++++++++++++++++++- src/app/mod.rs | 5 ++ src/app/spelling.rs | 12 +++- src/app/workspace.rs | 3 + src/help.rs | 7 ++ 6 files changed, 188 insertions(+), 6 deletions(-) diff --git a/src/app/editor.rs b/src/app/editor.rs index c8ba678..ded0ee9 100644 --- a/src/app/editor.rs +++ b/src/app/editor.rs @@ -197,6 +197,7 @@ impl App { .map(str::to_string) }); let mut accept: Option = None; + let mut dismiss: Option = 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; diff --git a/src/app/grammar.rs b/src/app/grammar.rs index 2dc2a18..c44fd09 100644 --- a/src/app/grammar.rs +++ b/src/app/grammar.rs @@ -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 = None; + let mut dismiss: Option = 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,13 +493,39 @@ 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, + 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. fn go_to_label(ui: &mut egui::Ui, text: egui::RichText) -> bool { @@ -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. diff --git a/src/app/mod.rs b/src/app/mod.rs index 0fc34e7..313d391 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, + /// 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, diff --git a/src/app/spelling.rs b/src/app/spelling.rs index 8d911b9..16a8de6 100644 --- a/src/app/spelling.rs +++ b/src/app/spelling.rs @@ -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()); + } } } diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 1484021..0e5583c 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -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; }; diff --git a/src/help.rs b/src/help.rs index 5adb501..51c3cc1 100644 --- a/src/help.rs +++ b/src/help.rs @@ -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(