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)
|
.map(str::to_string)
|
||||||
});
|
});
|
||||||
let mut accept: Option<String> = None;
|
let mut accept: Option<String> = None;
|
||||||
|
let mut dismiss: Option<usize> = None;
|
||||||
output.response.context_menu(|ui| {
|
output.response.context_menu(|ui| {
|
||||||
match &menu {
|
match &menu {
|
||||||
Some((i, reps)) if !reps.is_empty() => {
|
Some((i, reps)) if !reps.is_empty() => {
|
||||||
@@ -215,6 +216,19 @@ impl App {
|
|||||||
ui.label(egui::RichText::new("No spelling issue here").weak());
|
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
|
// A character or place name is not a misspelling; let it be
|
||||||
// accepted for good rather than corrected every time.
|
// accepted for good rather than corrected every time.
|
||||||
if let Some(word) = &target_word {
|
if let Some(word) = &target_word {
|
||||||
@@ -234,6 +248,9 @@ impl App {
|
|||||||
if let Some(word) = accept {
|
if let Some(word) = accept {
|
||||||
self.add_to_dictionary(&word);
|
self.add_to_dictionary(&word);
|
||||||
}
|
}
|
||||||
|
if let Some(i) = dismiss {
|
||||||
|
self.dismiss_issue(i);
|
||||||
|
}
|
||||||
if let Some((i, j)) = chosen {
|
if let Some((i, j)) = chosen {
|
||||||
self.apply_current_fix(i, j);
|
self.apply_current_fix(i, j);
|
||||||
self.spell_menu = None;
|
self.spell_menu = None;
|
||||||
|
|||||||
+147
-3
@@ -95,13 +95,15 @@ impl App {
|
|||||||
self.lt_rx = None;
|
self.lt_rx = None;
|
||||||
match result {
|
match result {
|
||||||
Ok(matches) => {
|
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(),
|
0 => "No issues found".to_string(),
|
||||||
1 => "1 issue".to_string(),
|
1 => "1 issue".to_string(),
|
||||||
n => format!("{n} issues"),
|
n => format!("{n} issues"),
|
||||||
};
|
};
|
||||||
self.lt_matches = matches;
|
|
||||||
self.lt_checked_text = text;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.lt_matches.clear();
|
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
|
/// Send the editor to the issue at `idx`, selecting it so it is obvious
|
||||||
/// which words the panel entry was talking about.
|
/// which words the panel entry was talking about.
|
||||||
pub(super) fn jump_to_issue(&mut self, idx: usize) {
|
pub(super) fn jump_to_issue(&mut self, idx: usize) {
|
||||||
@@ -338,6 +398,7 @@ impl App {
|
|||||||
/// spell-check results.
|
/// spell-check results.
|
||||||
pub(super) fn lt_panel(&mut self, ctx: &egui::Context) {
|
pub(super) fn lt_panel(&mut self, ctx: &egui::Context) {
|
||||||
let (source, items) = self.issue_items();
|
let (source, items) = self.issue_items();
|
||||||
|
let mut restore = false;
|
||||||
egui::TopBottomPanel::bottom("ltpanel")
|
egui::TopBottomPanel::bottom("ltpanel")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.default_height(190.0)
|
.default_height(190.0)
|
||||||
@@ -355,6 +416,14 @@ impl App {
|
|||||||
if ui.button("Hide").clicked() {
|
if ui.button("Hide").clicked() {
|
||||||
self.show_lt_panel = false;
|
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();
|
ui.separator();
|
||||||
@@ -370,11 +439,19 @@ impl App {
|
|||||||
|
|
||||||
let mut apply: Option<(usize, usize)> = None;
|
let mut apply: Option<(usize, usize)> = None;
|
||||||
let mut jump: Option<usize> = None;
|
let mut jump: Option<usize> = None;
|
||||||
|
let mut dismiss: Option<usize> = None;
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
for item in &items {
|
for item in &items {
|
||||||
ui.horizontal_wrapped(|ui| {
|
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 {
|
let col = if item.spelling {
|
||||||
egui::Color32::from_rgb(0xE0, 0x40, 0x40)
|
egui::Color32::from_rgb(0xE0, 0x40, 0x40)
|
||||||
} else {
|
} else {
|
||||||
@@ -416,12 +493,38 @@ impl App {
|
|||||||
// A fix wins over a bare jump: it moves the editor there too.
|
// A fix wins over a bare jump: it moves the editor there too.
|
||||||
if let Some((i, j)) = apply {
|
if let Some((i, j)) = apply {
|
||||||
self.apply_current_fix(i, j);
|
self.apply_current_fix(i, j);
|
||||||
|
} else if let Some(i) = dismiss {
|
||||||
|
self.dismiss_issue(i);
|
||||||
} else if let Some(i) = jump {
|
} else if let Some(i) = jump {
|
||||||
self.jump_to_issue(i);
|
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,
|
/// 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.
|
/// 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));
|
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]
|
#[test]
|
||||||
fn remap_drops_matches_overlapping_the_edit() {
|
fn remap_drops_matches_overlapping_the_edit() {
|
||||||
// A match straddling the edit boundary is discarded, not mis-shifted.
|
// 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
|
/// Index (into the currently displayed matches) of the word a right-click
|
||||||
/// suggestion menu is open for, if any.
|
/// suggestion menu is open for, if any.
|
||||||
spell_menu: Option<usize>,
|
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
|
/// Byte range in `buffer` the editor should select and scroll into view next
|
||||||
/// frame, set when an issue is clicked in the results panel.
|
/// frame, set when an issue is clicked in the results panel.
|
||||||
issue_jump: Option<(usize, usize)>,
|
issue_jump: Option<(usize, usize)>,
|
||||||
@@ -407,6 +411,7 @@ impl App {
|
|||||||
diff_title: String::new(),
|
diff_title: String::new(),
|
||||||
wordlist_input: String::new(),
|
wordlist_input: String::new(),
|
||||||
spell_menu: None,
|
spell_menu: None,
|
||||||
|
dismissed: HashSet::new(),
|
||||||
issue_jump: None,
|
issue_jump: None,
|
||||||
show_mistral_settings: false,
|
show_mistral_settings: false,
|
||||||
show_template_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());
|
let received = self.spell_rx.as_ref().and_then(|rx| rx.try_recv().ok());
|
||||||
if let Some((text, matches)) = received {
|
if let Some((text, matches)) = received {
|
||||||
self.spell_rx = None;
|
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_matches = matches;
|
||||||
self.spell_checked_text = text;
|
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.dirty = false;
|
||||||
self.pending_delete = false;
|
self.pending_delete = false;
|
||||||
self.clear_lt();
|
self.clear_lt();
|
||||||
|
self.dismissed.clear();
|
||||||
self.session_start_counts = self.snapshot_counts();
|
self.session_start_counts = self.snapshot_counts();
|
||||||
self.file_meta = self.snapshot_file_meta();
|
self.file_meta = self.snapshot_file_meta();
|
||||||
self.rebuild_field_names();
|
self.rebuild_field_names();
|
||||||
@@ -365,6 +366,8 @@ impl App {
|
|||||||
self.spell_dirty = true;
|
self.spell_dirty = true;
|
||||||
self.spell_last_edit = None;
|
self.spell_last_edit = None;
|
||||||
self.spell_menu = 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 {
|
let Some(name) = self.files.get(idx).cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 \
|
(grammar in blue, spelling in red) take over until you edit again, then the \
|
||||||
offline spell check resumes.",
|
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)");
|
section(ui, "Plot beats (Mistral)");
|
||||||
body(
|
body(
|
||||||
|
|||||||
Reference in New Issue
Block a user