M7: follow the observed Word for Windows proofing pane

Observe F7 in the installed Word with synthetic text, then replace the central correction form with a right-side task pane. Show sentence context, directly clickable suggestion cards and ignore/dictionary links. Use the existing pane host, palette and original icons; copy no Microsoft assets.

Keep per-window state and clear targets on document switch. Adapt UI tests, pseudo-localization exemptions and light/dark previews. Record observed measurements and remaining feature differences. Local cargo xtask ci was attempted but cargo is absent; exact-head hosted checks and visual comparison remain required.
This commit is contained in:
totoz64
2026-10-26 10:16:49 +01:00
parent b2787bf36e
commit f6f9d65b0e
14 changed files with 430 additions and 296 deletions
+2 -2
View File
@@ -3,8 +3,8 @@ name: Spelling review preview
on:
pull_request:
paths:
- 'crates/ui-egui/src/dialogs_spelling.rs'
- 'crates/ui-egui/src/dialogs_review.rs'
- 'crates/ui-egui/src/proofing_pane.rs'
- 'crates/ui-egui/src/panes.rs'
- 'crates/engine/src/cmd/review.rs'
- '.github/workflows/spelling-review.yml'
workflow_dispatch:
+3 -3
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -56,6 +56,7 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value {
json!({
"ui": app.prefs(),
"view": app.session.view,
"proofing": app.spelling_review,
"dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()),
"window": [r.width(), r.height()],
"canvasRect": app.canvas.canvas_rect.map(|c| [c.left(), c.top(), c.width(), c.height()]),
-4
View File
@@ -17,7 +17,6 @@ use crate::theme::{Tokens, semibold};
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "form", rename_all = "camelCase")]
pub enum ReviewDialog {
Spelling(Box<crate::dialogs_spelling::SpellingForm>),
Compare(CompareForm),
AutoCorrect(Box<AutoCorrectForm>),
}
@@ -25,7 +24,6 @@ pub enum ReviewDialog {
impl ReviewDialog {
pub fn name(&self) -> &'static str {
match self {
ReviewDialog::Spelling(_) => "spelling",
ReviewDialog::Compare(f) if f.combine => "combine",
ReviewDialog::Compare(_) => "compare",
ReviewDialog::AutoCorrect(_) => "autoCorrect",
@@ -35,7 +33,6 @@ impl ReviewDialog {
/// The window title (English; the caller translates it).
pub fn title(&self) -> &'static str {
match self {
ReviewDialog::Spelling(_) => "Spelling & Grammar",
ReviewDialog::Compare(f) if f.combine => "Combine Documents",
ReviewDialog::Compare(_) => "Compare Documents",
ReviewDialog::AutoCorrect(_) => "AutoCorrect",
@@ -60,7 +57,6 @@ pub fn open(name: &str, app: &mut WordApp) -> Option<ReviewDialog> {
/// Draws the dialog; returns true to close it.
pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut ReviewDialog) -> bool {
match d {
ReviewDialog::Spelling(f) => crate::dialogs_spelling::body(app, ui, f),
ReviewDialog::Compare(f) => compare_ui(app, ui, f),
ReviewDialog::AutoCorrect(f) => autocorrect_ui(app, ui, f),
}
-262
View File
@@ -1,262 +0,0 @@
//! A persistent proofing form over the shared review commands, independent of language data.
use egui::Ui;
use serde::Serialize;
use serde_json::{Value, json};
use wordcraft_engine::Selection;
use crate::WordApp;
#[derive(Clone, Debug, Serialize)]
pub struct SpellingForm {
issue: Value,
replacement: String,
message: String,
#[serde(skip)]
document: u64,
#[serde(skip)]
epoch: u64,
#[serde(skip)]
selection: Selection,
}
impl SpellingForm {
pub fn new(app: &WordApp, issue: Value) -> Self {
let replacement = issue.get("suggestions").and_then(Value::as_array).and_then(|a| a.first()).and_then(Value::as_str).unwrap_or("").into();
Self {
issue,
replacement,
message: String::new(),
document: app.session.document_id(),
epoch: app.session.ime_body_epoch(),
selection: app.session.sel.clone(),
}
}
fn done(&self) -> bool {
self.issue.get("done").and_then(Value::as_bool) == Some(true)
}
// Other windows and commands can change a target while this form is open.
// Never apply a cached suggestion to a different selection or document body.
fn current(&self, app: &mut WordApp) -> bool {
self.document == app.session.document_id()
&& self.epoch == app.session.ime_body_epoch()
&& self.selection == app.session.sel
&& app.session.run("review.suggestions", &json!({})).ok().as_ref() == Some(&self.issue)
}
fn next(&mut self, app: &mut WordApp, wrap: bool) {
match app.execute("review.spelling", json!({"wrap": wrap})) {
Ok(issue) => *self = Self::new(app, issue),
Err(e) => self.message = e,
}
}
fn act(&mut self, app: &mut WordApp, command: Option<&str>) {
if !self.current(app) {
self.message = tl!("The document or selection changed. Check again to continue.").into();
return;
}
if let Some(command) = command {
let params = if command == "review.applySuggestion" { json!({"text": self.replacement}) } else { json!({}) };
if let Err(e) = app.run(command, params) {
self.message = e;
return;
}
}
self.next(app, false);
}
}
/// Changing or ignoring advances within the same form; Close and Escape dismiss it.
pub fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) -> bool {
ui.set_width(420.0);
if form.done() {
ui.label(tl!("The spelling and grammar check is complete."));
} else {
let spelling = form.issue.get("kind").and_then(Value::as_str) == Some("spelling");
ui.horizontal_wrapped(|ui| {
ui.label(tl!(if spelling { "Spelling" } else { "Grammar" }));
ui.label(form.issue.get("lang").and_then(Value::as_str).unwrap_or(""));
});
ui.label(egui::RichText::new(form.issue.get("text").and_then(Value::as_str).unwrap_or("")).strong());
ui.label(tl!(form.issue.get("message").and_then(Value::as_str).unwrap_or("")));
ui.add_space(8.0);
ui.label(tl!("Suggestions"));
let suggestions: Vec<&str> = form.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
egui::ScrollArea::vertical().id_salt("spelling_suggestions").max_height(120.0).show(ui, |ui| {
if suggestions.is_empty() {
ui.label(egui::RichText::new(tl!("(no suggestions)")).italics());
}
for suggestion in &suggestions {
if ui.selectable_label(form.replacement == *suggestion, *suggestion).clicked() {
form.replacement = (*suggestion).into();
}
}
});
let label = ui.label(tl!("Change to:"));
ui.add(egui::TextEdit::singleline(&mut form.replacement).char_limit(1024).desired_width(400.0)).labelled_by(label.id);
let can_change = (!form.replacement.is_empty() || suggestions.contains(&"")) && crate::widgets::enabled(app, "review.applySuggestion");
let mut action = None;
ui.horizontal_wrapped(|ui| {
if ui.add_enabled(can_change, egui::Button::new(tl!("Change"))).clicked() {
action = Some(Some("review.applySuggestion"));
}
if ui.button(tl!("Ignore Once")).clicked() {
action = Some(None);
}
if spelling {
if ui.button(tl!("Ignore All")).clicked() {
action = Some(Some("review.ignoreAll"));
}
if ui.button(tl!("Add to Dictionary")).clicked() {
action = Some(Some("review.addToDictionary"));
}
}
});
if let Some(command) = action {
form.act(app, command);
}
}
if !form.message.is_empty() {
ui.colored_label(ui.visuals().error_fg_color, &form.message);
}
ui.add_space(8.0);
let mut close = false;
ui.horizontal(|ui| {
if ui.button(tl!("Check Again")).clicked() {
form.next(app, true);
}
close = ui.button(tl!("Close")).clicked();
});
close || crate::dialogs::cancel_key_pressed(ui)
}
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;
use wordcraft_doc::{Document, Pos, StoryRef};
use wordcraft_engine::Session;
use super::*;
use crate::{Services, dialogs::Dialog, dialogs_review::ReviewDialog};
fn app(text: &str) -> WordApp {
WordApp::new(Session::new(Document::from_text(text)), Services::default())
}
fn frame(ui: &mut Ui, app: &mut WordApp) {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
}
fn harness(text: &str) -> egui_kittest::Harness<'static, WordApp> {
let mut app = app(text);
app.run("review.spelling", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(frame, app);
h.run_steps(6);
h
}
fn form(app: &WordApp) -> &SpellingForm {
match &app.dialog {
Some(Dialog::Review(d)) => match &**d {
ReviewDialog::Spelling(f) => f,
_ => panic!("spelling form expected"),
},
_ => panic!("spelling dialog expected"),
}
}
#[test]
fn spelling_review_dialog_changes_a_suggestion_and_undo_restores_the_word() {
let mut h = harness("The quick brwn fox.");
assert!(!h.state().canvas.context_menu_open);
h.get_by_label("brown").click();
h.run_steps(2);
h.get_by_label("Change").click();
h.run_steps(3);
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox.");
assert!(form(h.state()).done());
h.get_by_label("Close").click();
h.run_steps(2);
assert!(h.state().dialog.is_none());
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brwn fox.");
}
#[test]
fn spelling_review_dialog_ignore_once_advances_and_finishes_without_changing_text() {
let text = "The brwn fox and brwn dog.";
let mut h = harness(text);
let first = h.state().session.sel.clone();
h.get_by_label("Ignore Once").click();
h.run_steps(3);
assert_ne!(h.state().session.sel, first);
assert_eq!(form(h.state()).issue["text"], "brwn");
h.get_by_label("Ignore Once").click();
h.run_steps(3);
assert!(form(h.state()).done());
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text);
h.get_by_label("Check Again").click();
h.run_steps(3);
assert_eq!(h.state().session.sel, first, "Ignore Once never adds a word to the dictionary");
}
#[test]
fn spelling_review_dialog_ignore_all_and_add_to_dictionary_advance() {
for (word, button) in [("zqproofingdialogignore", "Ignore All"), ("zqproofingdialogcustom", "Add to Dictionary")] {
let text = format!("The {word} fox and {word} dog.");
let mut h = harness(&text);
h.get_by_label(button).click();
h.run_steps(3);
assert!(form(h.state()).done());
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text);
assert!(h.state_mut().session.run("review.issues", &json!({})).unwrap().as_array().unwrap().is_empty());
}
}
#[test]
fn spelling_review_dialog_refuses_a_stale_selection_or_replaced_body() {
for replace in [false, true] {
let mut a = app("The brwn fox and brwn dog.");
let issue = a.execute("review.spelling", json!({})).unwrap();
assert!(a.dialog.is_none(), "programmatic review calls never open a window");
let mut f = SpellingForm::new(&a, issue);
f.replacement = "brown".into();
if replace {
a.execute("document.setText", json!({"text": "The brwn fox and brwn dog."})).unwrap();
} else {
a.session.sel = Selection::caret(Pos::body(0, 0));
}
let before = a.session.doc.plain_text(StoryRef::Body);
f.act(&mut a, Some("review.applySuggestion"));
assert!(!f.message.is_empty());
assert_eq!(a.session.doc.plain_text(StoryRef::Body), before);
}
}
#[test]
fn spelling_review_dialog_shows_completion_without_issues_and_escape_closes() {
let mut h = harness("The quick brown fox.");
assert!(form(h.state()).done());
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().dialog.is_none());
}
#[test]
#[ignore = "offscreen visual review; requires a software or hardware graphics adapter"]
fn spelling_review_dialog_preview() {
let mut a = app("The quick brwn fox.");
a.run("review.spelling", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).wgpu().build_ui_state(frame, a);
for (name, visuals) in [("light", egui::Visuals::light()), ("dark", egui::Visuals::dark())] {
h.ctx.set_visuals(visuals);
h.run_steps(6);
h.render().unwrap().save(format!("spelling-review-{name}.png")).unwrap();
}
}
}
+1
View File
@@ -49,6 +49,7 @@ impl WordApp {
self.canvas.show_other_document(seen);
self.controls = Default::default();
self.dialog = None;
self.spelling_review = None;
self.colors = None;
self.restrict = crate::dialogs_review::RestrictPane { open: self.restrict.open, ..Default::default() };
self.status_msg = None;
+4
View File
@@ -172,6 +172,10 @@ impl<'a> Exempt<'a> {
if let Some(sugg) = app.canvas.context_issue.as_ref().and_then(|i| i.get("suggestions")).and_then(Value::as_array) {
names.extend(sugg.iter().filter_map(Value::as_str).map(str::to_string));
}
// The proofing pane displays document context and dictionary suggestions, not UI wording.
if let Some(form) = &app.spelling_review {
names.extend(form.content_text());
}
Exempt { body: app.session.doc.plain_text(wordcraft_doc::StoryRef::Body), names, math: math_placeholders }
}
+8 -6
View File
@@ -45,7 +45,6 @@ pub mod dialogs_notes;
pub mod dialogs_para;
pub mod dialogs_refs;
pub mod dialogs_review;
pub mod dialogs_spelling;
pub mod dialogs_ribbon;
pub mod dialogs_smart_art;
pub mod dialogs_table_style;
@@ -74,6 +73,7 @@ pub mod paste_picture;
pub mod previews;
pub mod print_page;
pub mod printing;
pub mod proofing_pane;
pub mod read_aloud;
pub mod ribbon;
pub mod ribbon_fit;
@@ -590,6 +590,8 @@ pub struct WordApp {
pub colors: Option<colors::ColorsDialog>,
/// The Restrict Editing pane (#412).
pub restrict: dialogs_review::RestrictPane,
/// The language-aware spelling and grammar task pane.
pub spelling_review: Option<proofing_pane::SpellingForm>,
pub status_msg: Option<(String, f64)>,
pub previews: previews::Previews,
/// Media key of the picture a pending Change Picture replaces (#147).
@@ -778,6 +780,7 @@ impl WordApp {
dialog,
colors,
restrict,
spelling_review,
status_msg,
previews,
change_picture_target,
@@ -887,6 +890,7 @@ impl WordApp {
dialog,
colors,
restrict,
spelling_review,
status_msg,
previews,
change_picture_target,
@@ -1115,12 +1119,10 @@ impl WordApp {
if matches!(id, "insert.picture" | "insert.icon") {
self.select_inserted_picture(&r);
}
// User proofing opens a persistent correction form; scripts keep the engine's
// structured result. Right-click and Shift+F10 retain their compact suggestions.
// User proofing opens the persistent task pane; scripts keep structured results.
// Right-click and Shift+F10 retain their compact suggestions menu.
if matches!(id, "review.spelling" | "review.editor") && let Ok(v) = &r {
self.dialog = Some(dialogs::Dialog::Review(Box::new(dialogs_review::ReviewDialog::Spelling(Box::new(
dialogs_spelling::SpellingForm::new(self, v.clone()),
)))));
self.spelling_review = Some(proofing_pane::SpellingForm::new(self, v.clone()));
}
let r = system_clipboard::explain(self, id, r);
// Check Accessibility shows its findings in the Accessibility pane, kept up to date there.
+10 -2
View File
@@ -30,6 +30,7 @@ pub enum Pane {
AltText,
FormatShape,
RestrictEditing,
Proofing,
Comments,
}
@@ -45,6 +46,7 @@ impl Pane {
Self::AltText => "Alt Text",
Self::FormatShape => "Format Shape",
Self::RestrictEditing => "Restrict Editing",
Self::Proofing => "Proofing",
Self::Comments => "Comments",
}
}
@@ -60,6 +62,7 @@ impl Pane {
Self::AltText => (Edge::End, 260.0),
Self::FormatShape => (Edge::End, 280.0),
Self::RestrictEditing => (Edge::End, 270.0),
Self::Proofing => (Edge::End, 400.0),
Self::Comments => (Edge::End, 290.0),
};
PaneState::docked(edge, width)
@@ -77,11 +80,12 @@ impl Pane {
Self::AltText => "altText",
Self::FormatShape => "formatShape",
Self::RestrictEditing => "restrictEditing",
Self::Proofing => "proofing",
Self::Comments => "comments",
}
}
const ALL: [Self; 10] = [
const ALL: [Self; 11] = [
Self::SmartArtText,
Self::Navigation,
Self::Clipboard,
@@ -91,6 +95,7 @@ impl Pane {
Self::AltText,
Self::FormatShape,
Self::RestrictEditing,
Self::Proofing,
Self::Comments,
];
@@ -110,7 +115,7 @@ impl Pane {
Self::AltText => Some("view.altTextPane"),
Self::FormatShape => Some("view.formatShapePane"),
Self::Comments => Some("view.commentsPane"),
Self::RestrictEditing => None,
Self::RestrictEditing | Self::Proofing => None,
}
}
}
@@ -163,6 +168,7 @@ pub fn open(app: &WordApp) -> Vec<Pane> {
(Pane::AltText, v.alt_text_pane),
(Pane::FormatShape, v.format_shape_pane),
(Pane::RestrictEditing, app.restrict.open),
(Pane::Proofing, app.spelling_review.is_some()),
(Pane::Comments, v.comments_pane),
]
.into_iter()
@@ -289,6 +295,7 @@ impl PaneContent<Pane> for Content<'_> {
egui::ScrollArea::vertical().show(ui, |ui| crate::format_shape::pane(app, ui));
}
Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui),
Pane::Proofing => crate::proofing_pane::show(app, ui),
Pane::Comments => comments(app, ui),
}
}
@@ -444,6 +451,7 @@ fn close(app: &mut WordApp, pane: Pane) {
Some(id) => {
let _ = app.run(id, json!({"value": false}));
}
None if pane == Pane::Proofing => app.spelling_review = None,
None => app.restrict.open = false,
}
}
+369
View File
@@ -0,0 +1,369 @@
//! Language-aware proofing task pane, laid out from black-box observation of Word on Windows.
use egui::{Align, Align2, Sense, Stroke, Ui, vec2};
use serde::Serialize;
use serde_json::{Value, json};
use wordcraft_engine::Selection;
use crate::WordApp;
use crate::theme::{Tokens, regular, semibold};
#[derive(Clone, Debug, Serialize)]
pub struct SpellingForm {
issue: Value,
replacement: String,
message: String,
context_before: String,
context_after: String,
manual: bool,
#[serde(skip)]
document: u64,
#[serde(skip)]
epoch: u64,
#[serde(skip)]
selection: Selection,
}
impl SpellingForm {
pub fn new(app: &WordApp, issue: Value) -> Self {
let replacement = issue.get("suggestions").and_then(Value::as_array).and_then(|a| a.first()).and_then(Value::as_str).unwrap_or("").into();
let (a, b) = app.session.sel.ordered();
let para = app.session.doc.para_at(&a);
let before = para.and_then(|p| p.text.get(..a.off)).unwrap_or("").rsplit(['.', '!', '?']).next().unwrap_or("").trim_start();
let mut before: Vec<char> = before.chars().rev().take(90).collect();
before.reverse();
let after = para.and_then(|p| p.text.get(b.off..)).unwrap_or("").split_inclusive(['.', '!', '?']).next().unwrap_or("");
Self {
issue,
replacement,
message: String::new(),
context_before: before.into_iter().collect(),
context_after: after.chars().take(90).collect(),
manual: false,
document: app.session.document_id(),
epoch: app.session.ime_body_epoch(),
selection: app.session.sel.clone(),
}
}
fn done(&self) -> bool {
self.issue.get("done").and_then(Value::as_bool) == Some(true)
}
#[cfg(test)]
pub(crate) fn content_text(&self) -> Vec<String> {
let mut text: Vec<String> = self.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten()
.filter_map(Value::as_str).map(str::to_string).collect();
text.push(format!("{}{}{}", self.context_before, self.issue.get("text").and_then(Value::as_str).unwrap_or(""), self.context_after));
text
}
fn current(&self, app: &mut WordApp) -> bool {
self.document == app.session.document_id()
&& self.epoch == app.session.ime_body_epoch()
&& self.selection == app.session.sel
&& app.session.run("review.suggestions", &json!({})).ok().as_ref() == Some(&self.issue)
}
fn next(&mut self, app: &mut WordApp, wrap: bool) {
match app.execute("review.spelling", json!({"wrap": wrap})) {
Ok(issue) => *self = Self::new(app, issue),
Err(e) => self.message = e,
}
}
fn act(&mut self, app: &mut WordApp, command: Option<&str>) {
if !self.current(app) {
self.message = tl!("The document or selection changed. Check again to continue.").into();
return;
}
if let Some(command) = command {
let params = if command == "review.applySuggestion" { json!({"text": self.replacement}) } else { json!({}) };
if let Err(e) = app.run(command, params) {
self.message = e;
return;
}
}
self.next(app, false);
}
}
/// The shared task-pane host supplies the title, menu, Close button, docking and keyboard routing.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let Some(mut form) = app.spelling_review.take() else { return };
egui::Frame::NONE.inner_margin(egui::Margin { left: 4, right: 16, top: 0, bottom: 0 }).show(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| body(app, ui, &mut form));
});
app.spelling_review = Some(form);
}
fn action_link(ui: &mut Ui, text: &str) -> bool {
ui.add(egui::Button::new(egui::RichText::new(tl!(text)).color(Tokens::get(ui.ctx()).text).underline()).frame(false)).clicked()
}
fn context(ui: &mut Ui, app: &mut WordApp, form: &mut SpellingForm, spelling: bool) {
let t = Tokens::get(ui.ctx());
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(2.0).inner_margin(8).show(ui, |ui| {
ui.horizontal(|ui| {
let mut job = egui::text::LayoutJob::default();
let format = egui::TextFormat { font_id: regular(12.0), color: t.text, ..Default::default() };
job.append(&form.context_before, 0.0, format.clone());
let marked = egui::TextFormat { underline: Stroke::new(1.0, if spelling { t.red } else { t.blue }), ..format.clone() };
job.append(form.issue.get("text").and_then(Value::as_str).unwrap_or(""), 0.0, marked);
job.append(&form.context_after, 0.0, format);
ui.allocate_ui_with_layout(vec2((ui.available_width() - 36.0).max(20.0), 30.0), egui::Layout::top_down(Align::Min), |ui| {
ui.add(egui::Label::new(job).wrap());
});
let (rect, response) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Read Aloud")));
crate::icons::paint(ui.painter(), rect.shrink(4.0), "readAloud", t.icon, t.accent);
if response.on_hover_text(tl!("Read Aloud")).clicked() {
if form.current(app) {
if let Err(e) = app.run("review.readAloud", json!({})) {
form.message = e;
}
} else {
form.message = tl!("The document or selection changed. Check again to continue.").into();
}
}
});
});
}
fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) {
let t = Tokens::get(ui.ctx());
if form.done() {
ui.label(tl!("The spelling and grammar check is complete."));
ui.add_space(8.0);
if action_link(ui, "Check Again") {
form.next(app, true);
}
return;
}
let spelling = form.issue.get("kind").and_then(Value::as_str) == Some("spelling");
ui.label(egui::RichText::new(tl!(if spelling { "Spelling" } else { "Grammar" })).font(regular(14.0)))
.on_hover_text(form.issue.get("lang").and_then(Value::as_str).unwrap_or(""));
let message = if spelling { tl!("Not in Dictionary") } else { tl!(form.issue.get("message").and_then(Value::as_str).unwrap_or("")) };
ui.label(egui::RichText::new(message).font(regular(11.0)).color(t.text_dim));
ui.add_space(8.0);
context(ui, app, form, spelling);
ui.add_space(12.0);
ui.label(egui::RichText::new(tl!("Suggestions")).font(regular(11.0)).color(t.text_dim));
let suggestions: Vec<String> = form.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten()
.filter_map(Value::as_str).map(str::to_string).collect();
let mut change = None;
if suggestions.is_empty() {
ui.label(egui::RichText::new(tl!("(no suggestions)")).italics());
} else {
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(2.0).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
for (index, suggestion) in suggestions.iter().enumerate() {
ui.push_id(index, |ui| {
if index > 0 {
ui.separator();
}
ui.horizontal(|ui| {
let width = (ui.available_width() - 34.0).max(20.0);
let (rect, response) = ui.allocate_exact_size(vec2(width, 64.0), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, suggestion));
if response.hovered() {
ui.painter().rect_filled(rect, 0.0, t.hover);
}
ui.painter().with_clip_rect(rect.shrink2(vec2(10.0, 0.0))).text(
rect.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, suggestion, semibold(12.5), t.text,
);
if response.clicked() {
change = Some(suggestion.clone());
}
ui.menu_button("⌄", |ui| {
if ui.button(tl!("Change…")).clicked() {
form.replacement.clone_from(suggestion);
form.manual = true;
ui.close();
}
if spelling && ui.button(tl!("Add to AutoCorrect")).clicked() {
let word = form.issue.get("text").and_then(Value::as_str).unwrap_or("");
let params = json!({"add": {"from": word, "to": suggestion}});
if form.current(app) {
if let Err(e) = app.run("tools.autocorrect", params) {
form.message = e;
}
} else {
form.message = tl!("The document or selection changed. Check again to continue.").into();
}
ui.close();
}
if ui.button(tl!("AutoCorrect Options…")).clicked() {
let _ = app.run("tools.autocorrect", json!({}));
ui.close();
}
}).response.on_hover_text(tl!("More options"));
});
});
}
});
}
if let Some(replacement) = change {
form.replacement = replacement;
form.act(app, Some("review.applySuggestion"));
return;
}
ui.add_space(6.0);
if action_link(ui, "Ignore Once") {
form.act(app, None);
} else if spelling && action_link(ui, "Ignore All") {
form.act(app, Some("review.ignoreAll"));
} else if spelling && action_link(ui, "Add to Dictionary") {
form.act(app, Some("review.addToDictionary"));
}
if suggestions.is_empty() && action_link(ui, "Change…") {
form.manual = true;
}
if form.manual {
ui.add_space(8.0);
let label = ui.label(tl!("Change to:"));
ui.add(egui::TextEdit::singleline(&mut form.replacement).char_limit(1024)).labelled_by(label.id);
if ui.button(tl!("Change")).clicked() {
form.act(app, Some("review.applySuggestion"));
}
}
if !form.message.is_empty() {
ui.colored_label(ui.visuals().error_fg_color, &form.message);
if action_link(ui, "Check Again") {
form.next(app, true);
}
}
}
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;
use wordcraft_doc::{Document, Pos, StoryRef};
use wordcraft_engine::Session;
use super::*;
use crate::Services;
fn app(text: &str) -> WordApp {
WordApp::new(Session::new(Document::from_text(text)), Services::default())
}
fn frame(ui: &mut Ui, app: &mut WordApp) {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
}
fn harness(text: &str) -> egui_kittest::Harness<'static, WordApp> {
let mut app = app(text);
app.run("review.spelling", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(frame, app);
h.run_steps(6);
h
}
fn form(app: &WordApp) -> &SpellingForm {
app.spelling_review.as_ref().expect("proofing pane expected")
}
#[test]
fn spelling_review_dialog_changes_a_suggestion_and_undo_restores_the_word() {
let mut h = harness("The quick brwn fox.");
assert!(!h.state().canvas.context_menu_open);
h.get_by_label("brown").click();
h.run_steps(3);
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox.");
assert!(form(h.state()).done());
h.get_by_label("Close").click();
h.run_steps(2);
assert!(h.state().spelling_review.is_none());
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brwn fox.");
}
#[test]
fn spelling_review_dialog_ignore_once_advances_and_finishes_without_changing_text() {
let text = "The brwn fox and brwn dog.";
let mut h = harness(text);
let first = h.state().session.sel.clone();
h.get_by_label("Ignore Once").click();
h.run_steps(3);
assert_ne!(h.state().session.sel, first);
assert_eq!(form(h.state()).issue["text"], "brwn");
h.get_by_label("Ignore Once").click();
h.run_steps(3);
assert!(form(h.state()).done());
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text);
h.get_by_label("Check Again").click();
h.run_steps(3);
assert_eq!(h.state().session.sel, first, "Ignore Once never adds a word to the dictionary");
}
#[test]
fn spelling_review_dialog_ignore_all_and_add_to_dictionary_advance() {
for (word, button) in [("zqproofingdialogignore", "Ignore All"), ("zqproofingdialogcustom", "Add to Dictionary")] {
let text = format!("The {word} fox and {word} dog.");
let mut h = harness(&text);
h.get_by_label(button).click();
h.run_steps(3);
assert!(form(h.state()).done());
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text);
assert!(h.state_mut().session.run("review.issues", &json!({})).unwrap().as_array().unwrap().is_empty());
}
}
#[test]
fn spelling_review_dialog_refuses_a_stale_selection_or_replaced_body() {
for replace in [false, true] {
let mut a = app("The brwn fox and brwn dog.");
let issue = a.execute("review.spelling", json!({})).unwrap();
assert!(a.dialog.is_none() && a.spelling_review.is_none(), "programmatic calls never open a pane or window");
let mut f = SpellingForm::new(&a, issue);
f.replacement = "brown".into();
if replace {
a.execute("document.setText", json!({"text": "The brwn fox and brwn dog."})).unwrap();
} else {
a.session.sel = Selection::caret(Pos::body(0, 0));
}
let before = a.session.doc.plain_text(StoryRef::Body);
f.act(&mut a, Some("review.applySuggestion"));
assert!(!f.message.is_empty());
assert_eq!(a.session.doc.plain_text(StoryRef::Body), before);
}
}
#[test]
fn spelling_review_dialog_shows_completion_without_issues_and_close_returns_to_document() {
let mut h = harness("The quick brown fox.");
assert!(form(h.state()).done());
h.get_by_label("Close").click();
h.run_steps(2);
assert!(h.state().spelling_review.is_none());
}
#[test]
#[ignore = "offscreen visual review; requires a software or hardware graphics adapter"]
fn spelling_review_dialog_preview() {
let mut a = app("The quick brwn fox.");
a.run("review.spelling", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).wgpu().build_ui_state(frame, a);
for (name, visuals) in [("light", egui::Visuals::light()), ("dark", egui::Visuals::dark())] {
h.ctx.set_visuals(visuals);
h.run_steps(6);
h.render().unwrap().save(format!("spelling-review-{name}.png")).unwrap();
}
}
#[test]
fn spelling_review_dialog_pane_is_local_to_each_window_and_clears_on_document_switch() {
let mut a = app("The brwn fox.");
a.run("review.spelling", json!({})).unwrap();
let mut other = crate::window_ui::WindowUi::new(a.session.document_id());
a.swap_window(&mut other);
assert!(a.spelling_review.is_none());
a.swap_window(&mut other);
assert_eq!(form(&a).issue["text"], "brwn");
a.run("file.new", json!({})).unwrap();
assert!(a.spelling_review.is_none());
}
}
+4
View File
@@ -22,6 +22,7 @@ pub(crate) struct WindowUi {
pub(crate) dialog: Option<dialogs::Dialog>,
pub(crate) colors: Option<colors::ColorsDialog>,
pub(crate) restrict: dialogs_review::RestrictPane,
pub(crate) spelling_review: Option<crate::proofing_pane::SpellingForm>,
pub(crate) status_msg: Option<(String, f64)>,
pub(crate) previews: previews::Previews,
pub(crate) change_picture_target: Option<String>,
@@ -91,6 +92,7 @@ impl WindowUi {
dialog: None,
colors: None,
restrict: Default::default(),
spelling_review: None,
status_msg: None,
previews: Default::default(),
change_picture_target: None,
@@ -212,6 +214,7 @@ impl WordApp {
dialog,
colors,
restrict,
spelling_review,
status_msg,
previews,
change_picture_target,
@@ -308,6 +311,7 @@ impl WordApp {
dialog,
colors,
restrict,
spelling_review,
status_msg,
previews,
change_picture_target,
+3 -3
View File
File diff suppressed because one or more lines are too long
+19 -10
View File
@@ -1,24 +1,33 @@
# Spelling and grammar correction form
# Spelling and grammar task pane
> **Last updated:** 2026-10-26 · **Status:** implementation proposed; hosted checks and visual acceptance must be verified before integration.
> **Last updated:** 2026-10-26 · **Status:** implementation proposed; hosted checks and rendered comparison remain required before integration.
Review › Spelling & Grammar, Home › Editor and F7 open a persistent correction form. The issue's text, explanation and proofing language appear above the suggestions. Choose a suggestion or type replacement text in Change to, then choose Change. Each replacement uses the existing review command and is one undoable document edit. Ignore Once advances without changing the document or dictionaries. Ignore All lasts for the session; Add to Dictionary uses the existing personal dictionary. Those two actions are offered only for spelling issues.
## Word reference
The pass begins at the next issue from the current selection and continues to the end of the current story (body, header, note or other active text). It does not traverse all document stories or restrict itself to a selected range. Check Again starts another search with wrapping; ignored-once occurrences can be revisited. Completion stays visible until Close or Escape. The compact right-click/Shift+F10 menu is retained.
Observed the actual Word for Windows interface on the preparation PC on 2026-10-26, using a newly created document with the synthetic sentence "Le chat mange une souri. Ce documant contient une faute." F7 selected "documant" and opened a right-side pane titled Vérification. Existing documents were left unchanged. The exact Word build was not recorded.
The form consumes language-aware engine results. It works with the existing proofing languages and is independent of French contribution #3185; adding an interface does not install a dictionary or add grammar rules. Interface labels use the shared translation lookup and fall back to English when a catalog lacks a key. No release date or completeness increase is implied.
At a 1920 × 1152 window, the pane occupied approximately 400 logical pixels on the right. Its body had a category heading and dictionary explanation, a framed sentence context approximately 48 px high, a Suggestions heading, and three bordered suggestion rows approximately 68 px high. Ignore Once, Ignore All and Add to Dictionary were text actions below the list. A suggestion's chevron opened Read Aloud, Spell Out, Change All and AutoCorrect actions. These are observations, not a copied screenshot or asset.
If another command changes the document body or selection while the form is open, an old suggestion is refused. Check Again obtains a fresh target. Read-only/protected document commands retain the engine's existing checks and display refusals in the form.
WordCraft uses this arrangement and its shared pane host, icons, typography and theme palette. Clicking a suggestion applies the correction directly and advances; the central modal proposal is superseded. The pane supports sentence context, Read Aloud through the existing command, manual replacement and AutoCorrect actions. Word's dictionary definitions/synonyms, Spell Out and Change All are not implemented here. Visual similarity must be assessed from the generated light/dark previews; implementation alone is not evidence of pixel identity.
## Scope and behavior
Review › Spelling & Grammar, Home › Editor and F7 open the task pane. The pass begins at the next issue from the current selection and continues to the end of the current story (body, header, note or other active text), not all document stories or only a selection. Each correction uses the existing review command and remains undoable. Ignore Once advances without changing the document or dictionaries. Ignore All lasts for the session; Add to Dictionary uses the personal dictionary. Those two actions are offered only for spelling issues. Completion remains visible; Check Again starts a wrapping search. The pane host supplies Close and docking.
If another command changes the document body or selection, a cached suggestion is refused; Check Again obtains a fresh target. Read-only/protected document commands retain their checks. The pane is local to each window, clears on a document switch and does not alter the compact right-click menu. Programmatic review commands return structured results without opening UI.
This work is independent of French contribution #3185. It consumes language-aware engine results and adds neither dictionaries nor grammar rules. Labels use the external shared translation lookup with English fallback for missing keys. No release date or readiness increase is implied.
## Verification
- Engine regression: one- and two-paragraph navigation, forward-only completion and explicit wrapping.
- UI regressions: suggestion application and undo, Ignore Once progress/completion, Ignore All, personal dictionary addition, stale selection/body refusal, no-issue completion and Escape.
- Offscreen preview: the ignored spelling_review_dialog_preview test writes light/dark PNGs; inspect them before integration.
- Required repository gate: cargo xtask ci. Cargo is unavailable on the preparation PC, so hosted checks are required; test definitions alone are not evidence of success.
- Engine: one-/two-paragraph navigation, forward-only completion and explicit wrapping.
- UI: direct suggestion correction and undo, Ignore Once progress/completion, Ignore All, personal dictionary addition, stale selection/body refusal, completion and Close, per-window isolation and document switching.
- Offscreen: the ignored spelling_review_dialog_preview test writes light/dark PNGs for comparison with the recorded Word arrangement.
- Required gate: cargo xtask ci. Cargo is unavailable on the preparation PC; hosted validation is required, and test definitions alone do not establish success.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | Actual Word for Windows observation replaces the modal proposal with a right-side Proofing pane, sentence context and directly clickable suggestion cards |
| 2026-10-26 | minor | Separate language-independent correction-form proposal and verification scope |
+6 -4
View File
File diff suppressed because one or more lines are too long