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:
@@ -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
@@ -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,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<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.
|
||||
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.
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user