mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 00:40:33 -06:00
Merge remote-tracking branch 'origin/main' into pr1104
# Conflicts: # ROADMAP.md
This commit is contained in:
@@ -355,6 +355,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. |
|
||||
| 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in |
|
||||
|---|---|
|
||||
| 2026-10-26 | Spelling & Grammar opens a right-side Proofing task pane observed in Word for Windows: sentence context, directly clickable suggestion cards and ignore/dictionary actions. Current-story traversal and stale-target checks are retained. Right-click keeps its compact menu. |
|
||||
| 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures |
|
||||
| 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. |
|
||||
| 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line |
|
||||
@@ -686,8 +687,12 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
|
||||
## Revision history
|
||||
|
||||
> 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged.
|
||||
>
|
||||
> 2026-10-28 · Markdown import bounds reference-label scans and normalization within the existing link-work allowance. Nested undefined labels preserve literal text instead of causing quadratic work (#4658).
|
||||
>
|
||||
> 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged.
|
||||
>
|
||||
> 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
@@ -706,6 +711,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) |
|
||||
| 2026-10-30 | trivial | Progress log: only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them (#4734) |
|
||||
| 2026-10-29 | trivial | Progress log: documents with links print (#4734) |
|
||||
| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged |
|
||||
| 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged |
|
||||
| 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) |
|
||||
| 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Connector object lookups retain the typed column-selection inputs on current main (#3631); readiness estimates unchanged |
|
||||
@@ -721,6 +728,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips |
|
||||
| 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) |
|
||||
| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. |
|
||||
| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged |
|
||||
| 2026-10-22 | trivial | Progress log: one native window per open document |
|
||||
| 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy |
|
||||
| 2026-10-22 | trivial | Progress log: several open documents in the desktop window |
|
||||
|
||||
@@ -80,6 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("review.changes", "Reviewing Pane", "Review › Tracking", list_changes).pure(),
|
||||
CommandSpec::new("review.spelling", "Spelling & Grammar", "Review › Proofing", next_issue)
|
||||
.key(super::mac_or("F7 / Alt+F7 / Mod+Alt+L", "F7 / Alt+F7"))
|
||||
.params(r#"{"wrap"?: bool (default true; false stops at the end of the current story)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("review.issues", "Proofing Issues", "Review › Proofing", all_issues).pure(),
|
||||
CommandSpec::new("review.suggestions", "Spelling Suggestions", "Review › Proofing", suggestions).params(r#"{"pos"?: Pos}"#).pure(),
|
||||
|
||||
@@ -55,7 +55,16 @@ fn para_issues(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Vec<
|
||||
|
||||
/// The issue under a position.
|
||||
pub(super) fn issue_at(s: &Session, at: &Pos) -> Option<(Pos, Pos, wordcraft_proof::Issue)> {
|
||||
para_issues(s, at.story, &at.path).into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off)
|
||||
let issues = para_issues(s, at.story, &at.path);
|
||||
let selected = s.sel.ordered();
|
||||
// F7 can select a spelling issue inside a larger grammar issue. Keep that
|
||||
// exact target for either selection direction before falling back to a point.
|
||||
if at == &s.sel.focus
|
||||
&& let Some(issue) = issues.iter().find(|(a, b, _)| a == &selected.0 && b == &selected.1)
|
||||
{
|
||||
return Some(issue.clone());
|
||||
}
|
||||
issues.into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off)
|
||||
}
|
||||
|
||||
fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Value {
|
||||
@@ -69,15 +78,23 @@ fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Valu
|
||||
}
|
||||
|
||||
/// F7: select the next issue after the caret and return it with suggestions.
|
||||
pub(super) fn next_issue(s: &mut Session, _: &Value) -> CmdResult {
|
||||
pub(super) fn next_issue(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let caret = s.sel.ordered().1;
|
||||
let story = caret.story;
|
||||
let paths = s.doc.para_paths(story);
|
||||
let start = paths.iter().position(|p| *p == caret.path).unwrap_or(0);
|
||||
for k in 0..paths.len() {
|
||||
let Some(path) = paths.get((start + k) % paths.len().max(1)) else { break };
|
||||
let wrap = p::bool(v, "wrap").unwrap_or(true);
|
||||
// Scan forward first, including the rest of the caret's paragraph. A second pass
|
||||
// optionally visits the earlier part; never select the first typo repeatedly in
|
||||
// a single-paragraph story. Dialogs disable wrapping so Ignore Once can finish.
|
||||
for (path, before) in paths
|
||||
.iter()
|
||||
.skip(start)
|
||||
.map(|path| (path, false))
|
||||
.chain(paths.iter().take(if wrap { start.saturating_add(1) } else { 0 }).map(|path| (path, true)))
|
||||
{
|
||||
for (a, b, i) in para_issues(s, story, path) {
|
||||
if k == 0 && a.off < caret.off && start + k < paths.len() && paths.len() > 1 {
|
||||
if *path == caret.path && (a.off < caret.off) != before {
|
||||
continue;
|
||||
}
|
||||
let r = issue_json(s, &a, &b, &i);
|
||||
@@ -103,8 +120,13 @@ pub(super) fn all_issues(s: &mut Session, _: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
pub(super) fn suggestions(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let at = v.get("pos").and_then(crate::cmd::parse_pos).unwrap_or_else(|| s.sel.focus.clone());
|
||||
match issue_at(s, &at) {
|
||||
// Explicit positions are point lookups, even when they equal the selection focus.
|
||||
// Only an implicit or unparseable position keeps the exact selected issue.
|
||||
let issue = match v.get("pos").and_then(crate::cmd::parse_pos) {
|
||||
Some(at) => para_issues(s, at.story, &at.path).into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off),
|
||||
None => issue_at(s, &s.sel.focus),
|
||||
};
|
||||
match issue {
|
||||
Some((a, b, i)) => Ok(issue_json(s, &a, &b, &i)),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
|
||||
@@ -1058,3 +1058,66 @@ fn rtf_tracked_changes_accept_and_reject_after_opening_and_round_trip() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod spelling_navigation_tests {
|
||||
use super::*;
|
||||
use wordcraft_doc::{Document, Pos, StoryRef};
|
||||
|
||||
use crate::Selection;
|
||||
|
||||
#[test]
|
||||
fn spelling_review_dialog_navigation_moves_forward_and_wraps_only_when_requested() {
|
||||
for text in ["The brwn fox and brwn dog.", "The brwn fox.\nThe brwn dog."] {
|
||||
let mut s = Session::new(Document::from_text(text));
|
||||
let first = s.run("review.spelling", &json!({"wrap": false})).unwrap();
|
||||
assert_eq!(first["text"], "brwn");
|
||||
let first_selection = s.sel.clone();
|
||||
let second = s.run("review.spelling", &json!({"wrap": false})).unwrap();
|
||||
assert_eq!(second["text"], "brwn");
|
||||
assert_ne!(s.sel, first_selection);
|
||||
assert_eq!(s.run("review.spelling", &json!({"wrap": false})).unwrap()["done"], true);
|
||||
assert_eq!(s.run("review.spelling", &json!({})).unwrap()["start"], first["start"]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn spelling_review_dialog_navigation_resolves_selected_spelling_before_overlapping_grammar() {
|
||||
for reverse in [false, true] {
|
||||
let mut s = Session::new(Document::from_text("A sentense sentense."));
|
||||
s.sel = Selection::caret(Pos::body(0, 11));
|
||||
let selected = s.run("review.spelling", &json!({"wrap": false})).unwrap();
|
||||
assert_eq!(selected["kind"], "spelling");
|
||||
assert_eq!(selected["start"]["off"], 11);
|
||||
assert_eq!(selected["text"], "sentense");
|
||||
let issues = s.run("review.issues", &json!({})).unwrap();
|
||||
assert!(issues.as_array().unwrap().iter().any(|i| i["kind"] == "grammar" && i["text"] == "sentense sentense"));
|
||||
if reverse {
|
||||
std::mem::swap(&mut s.sel.anchor, &mut s.sel.focus);
|
||||
}
|
||||
assert_eq!(s.run("review.suggestions", &json!({})).unwrap(), selected);
|
||||
let before_lookup = s.sel.clone();
|
||||
let focus = s.sel.focus.off;
|
||||
let explicit = s.run("review.suggestions", &json!({"pos": {"block": 0, "off": focus}})).unwrap();
|
||||
assert_eq!(explicit["kind"], "grammar", "an explicit focus is a point lookup in either selection direction");
|
||||
assert_eq!(explicit["text"], "sentense sentense");
|
||||
assert_eq!(explicit["start"]["off"], 2);
|
||||
assert_eq!(explicit["end"]["off"], 19);
|
||||
assert_eq!(s.sel, before_lookup, "suggestion lookups do not move the selection");
|
||||
for pos in [serde_json::Value::Null, json!({"off": "invalid"})] {
|
||||
assert_eq!(
|
||||
s.run("review.suggestions", &json!({"pos": pos})).unwrap(),
|
||||
selected,
|
||||
"unparseable positions preserve implicit selection lookup"
|
||||
);
|
||||
}
|
||||
assert_eq!(s.run("review.suggestions", &json!({})).unwrap(), selected);
|
||||
let elsewhere = s.run("review.suggestions", &json!({"pos": {"block": 0, "off": 5}})).unwrap();
|
||||
assert_eq!(elsewhere["kind"], "spelling");
|
||||
assert_eq!(elsewhere["start"]["off"], 2, "an explicit other position keeps point lookup");
|
||||
s.run("review.applySuggestion", &json!({"text": "sentence"})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "A sentense sentence.");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "A sentense sentense.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ impl WordApp {
|
||||
dialog,
|
||||
colors,
|
||||
restrict,
|
||||
spelling_review,
|
||||
status_msg,
|
||||
previews,
|
||||
change_picture_target,
|
||||
@@ -148,6 +149,7 @@ impl WordApp {
|
||||
dialog,
|
||||
colors,
|
||||
restrict,
|
||||
spelling_review,
|
||||
status_msg,
|
||||
previews,
|
||||
change_picture_target,
|
||||
|
||||
@@ -85,15 +85,12 @@ impl WordApp {
|
||||
if matches!(id, "insert.picture" | "insert.icon") {
|
||||
self.select_inserted_picture(&r);
|
||||
}
|
||||
// review.spelling only moves the selection to the next issue; without this, the ribbon's
|
||||
// Spelling & Grammar and Editor buttons looked broken — just a selection with no way to
|
||||
// see or apply a suggestion (#3340). Reuse the context menu's suggestions, as Shift+F10
|
||||
// already does for the word at the caret.
|
||||
if id == "review.spelling"
|
||||
// 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
|
||||
&& v.get("done").and_then(serde_json::Value::as_bool) != Some(true)
|
||||
{
|
||||
self.canvas.context_menu_from_keys = true;
|
||||
self.spelling_review = Some(crate::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.
|
||||
|
||||
@@ -2530,38 +2530,6 @@ fn control_click_on_macos_opens_the_context_menu() {
|
||||
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox");
|
||||
}
|
||||
|
||||
/// #3340: review.spelling (the Review tab's Spelling & Grammar button and the Home tab's
|
||||
/// Editor button both run it) only moved the selection to the next issue — with no way to see
|
||||
/// or apply a suggestion from there, just the generic selection toolbar, it looked broken.
|
||||
/// Running it now also opens the same suggestions menu Shift+F10 and a Control-click already
|
||||
/// open for the word at the caret.
|
||||
#[test]
|
||||
fn spelling_and_grammar_opens_suggestions_for_the_issue_it_selects() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "The quick brwn fox"})).unwrap();
|
||||
a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0));
|
||||
a.run("review.spelling", json!({})).unwrap();
|
||||
assert!(a.canvas.context_menu_from_keys, "queues the open the context menu already uses for Shift+F10");
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.state().canvas.context_menu_open, "Spelling & Grammar opens the suggestions menu");
|
||||
h.get_by_label("brown").click();
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_picture_can_be_dragged_in_front_of_or_behind_text() {
|
||||
for wrap in ["inFrontOfText", "behindText"] {
|
||||
|
||||
@@ -20,6 +20,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()]),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,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;
|
||||
@@ -476,6 +477,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).
|
||||
|
||||
@@ -56,6 +56,7 @@ pub enum Pane {
|
||||
AltText,
|
||||
FormatShape,
|
||||
RestrictEditing,
|
||||
Proofing,
|
||||
Comments,
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ impl Pane {
|
||||
Self::AltText => "Alt Text",
|
||||
Self::FormatShape => "Format Shape",
|
||||
Self::RestrictEditing => "Restrict Editing",
|
||||
Self::Proofing => "Proofing",
|
||||
Self::Comments => "Comments",
|
||||
}
|
||||
}
|
||||
@@ -86,6 +88,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)
|
||||
@@ -103,11 +106,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,
|
||||
@@ -117,6 +121,7 @@ impl Pane {
|
||||
Self::AltText,
|
||||
Self::FormatShape,
|
||||
Self::RestrictEditing,
|
||||
Self::Proofing,
|
||||
Self::Comments,
|
||||
];
|
||||
|
||||
@@ -136,7 +141,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +165,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,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()
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! 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());
|
||||
let width = ui.available_width();
|
||||
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.set_min_width((width - 18.0).max(0.0));
|
||||
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);
|
||||
let text_width = (ui.available_width() - 36.0).max(20.0);
|
||||
ui.allocate_ui_with_layout(vec2(text_width, 30.0), egui::Layout::top_down(Align::Min), |ui| {
|
||||
ui.set_min_width(text_width);
|
||||
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::Role::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;
|
||||
egui::ScrollArea::vertical().id_salt("proofing_suggestions").max_height(210.0).show(ui, |ui| {
|
||||
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::Role::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);
|
||||
let replacement = form(h.state()).issue["suggestions"][0].as_str().unwrap().to_string();
|
||||
h.get_by_label(&replacement).click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), format!("The quick {replacement} 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_selected_spelling_stays_actionable_when_grammar_overlaps() {
|
||||
let mut a = app("A sentense sentense.");
|
||||
a.session.sel = Selection::caret(Pos::body(0, 11));
|
||||
a.run("review.spelling", json!({"wrap": false})).unwrap();
|
||||
let mut f = a.spelling_review.take().unwrap();
|
||||
assert_eq!(f.issue["kind"], "spelling");
|
||||
assert!(f.current(&mut a), "the selected typo is current despite overlapping grammar");
|
||||
f.replacement = "sentence".into();
|
||||
f.act(&mut a, Some("review.applySuggestion"));
|
||||
assert!(f.message.is_empty());
|
||||
assert_eq!(a.session.doc.plain_text(StoryRef::Body), "A sentense sentence.");
|
||||
a.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(a.session.doc.plain_text(StoryRef::Body), "A sentense sentense.");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -213,6 +215,7 @@ impl WordApp {
|
||||
dialog,
|
||||
colors,
|
||||
restrict,
|
||||
spelling_review,
|
||||
status_msg,
|
||||
previews,
|
||||
change_picture_target,
|
||||
@@ -309,6 +312,7 @@ impl WordApp {
|
||||
dialog,
|
||||
colors,
|
||||
restrict,
|
||||
spelling_review,
|
||||
status_msg,
|
||||
previews,
|
||||
change_picture_target,
|
||||
|
||||
+6
-1
@@ -70,7 +70,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc
|
||||
| 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 7 | **Dialog depth**: 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Dialog depth**: Word-observed spelling/grammar task pane added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
|
||||
| 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) |
|
||||
| 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
|
||||
@@ -146,6 +146,8 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
|
||||
|
||||
## Revision history
|
||||
|
||||
> 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged.
|
||||
>
|
||||
> 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged.
|
||||
|
||||
> 2026-10-23 · cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison.
|
||||
@@ -163,6 +165,8 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
|
||||
| 2026-10-31 | trivial | A list level above that hasn't counted yet takes its first number at the first deeper item, as measured in Word (10.1–10.4, then 11); not a ranked gap; readiness estimates unchanged |
|
||||
| 2026-10-30 | trivial | Only the PDF sent to the printer omits link annotations; File › Print › Save these pages as PDF (`file.printPdf`) keeps them, like Export (#4734); no ranked gap changed; readiness estimates unchanged |
|
||||
| 2026-10-29 | trivial | The print job's PDF omits link annotations, which the spooler refused to fit to the paper, so documents with links reach the printer (#4734); physical printer output remains to be validated; no ranked gap changed; readiness estimates unchanged |
|
||||
| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged |
|
||||
| 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged |
|
||||
| 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) |
|
||||
| 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Connector object lookups retain the typed column-selection inputs on current main (#3631); readiness estimates unchanged |
|
||||
@@ -175,6 +179,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
|
||||
| 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching |
|
||||
| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. |
|
||||
| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. |
|
||||
| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged |
|
||||
| 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine |
|
||||
| 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change |
|
||||
| 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design |
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Spelling and grammar task pane
|
||||
|
||||
> **Last updated:** 2026-11-01 · **Status:** the feature-specific preview workflow is removed to follow the shared-CI policy. Rust implementation and tests are unchanged from `3abe452`; validation of this documentation/CI-only follow-up SHA is pending. Historical parent `3abe452` passed its exact-head hosted gate, three-platform Printing and proofing preview; those results remain attributed to that parent.
|
||||
|
||||
## Word reference
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Technical synchronization (2026-10-31)
|
||||
|
||||
The existing pane is merged with main `e6a5cd05f045e4caf25a28660bfeb622f25b5129`, retaining its Macros dialog, keytips and print-pipeline changes. The proofing-specific source and regressions are unchanged from `7c20316`: a valid explicit position remains a point lookup at the focus, while an absent/unparseable position preserves exact-selection priority. Navigation, correction/undo, compact menus and per-window state are retained. Fresh exact-SHA hosted checks and rendered previews must validate this synchronization; no new feature or readiness increase is claimed.
|
||||
|
||||
## Technical synchronization and API follow-up (2026-10-30)
|
||||
|
||||
The existing pane is merged with main `3b7f7ed10b2eb0ae3c39b71551d36ed2df1d1d8b`, retaining the upstream menus, pane host and per-window state. For `review.suggestions`, a successfully parsed explicit `pos` is a pure point lookup even when it equals the selection focus; an absent or unparseable `pos` keeps exact-selection priority. The follow-up preserves correction and undo; fresh hosted checks must validate its new SHA. Results for its parent `c077c155` do not validate the follow-up.
|
||||
|
||||
## Technical synchronization (2026-10-27)
|
||||
|
||||
The language-independent pane is rebased by a three-way merge with main `436d444ef0ee35c2e2b9fb44f87aa7d08d72641f`. Engine navigation and exact selected-range lookup live in `crates/engine/src/cmd/review/proofing.rs`; the existing regressions follow into `review/tests.rs`. The UI command hook follows into `app/commands.rs`, construction into `app.rs`, inspection into `control/handler.rs`, and pane state/closure into `panes/state.rs` and `panes/host.rs`. The upstream context-menu and dropdown implementations stay intact. Workflow path filters cover the extracted modules while retaining historical paths. No dictionary, grammar rule, new feature or readiness increase is introduced by this synchronization.
|
||||
|
||||
## Verification
|
||||
|
||||
- Engine: one-/two-paragraph navigation, forward-only completion, explicit wrapping, exact spelling selection within overlapping grammar in either selection direction, explicit point lookup at the focus, absent/unparseable-position selection fallback, unchanged selection, correction and undo.
|
||||
- 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`, through the repository's normal shared CI. The feature adds no workflow or shared-CI change. Local attempt outcomes are recorded separately; an environment-limited attempt does not replace hosted checks. Each follow-up SHA requires its own results.
|
||||
|
||||
### Manual regression and visual review
|
||||
|
||||
From the repository root, keep `Cargo.lock` and the existing dependency pins, use the normal native UI build prerequisites, and run:
|
||||
|
||||
```sh
|
||||
cargo test --locked -p wordcraft-engine --lib spelling_review_dialog_navigation
|
||||
cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog -- --skip preview
|
||||
cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog_preview -- --ignored --nocapture
|
||||
```
|
||||
|
||||
The filters currently select two engine regressions, seven UI regressions and one explicitly ignored preview test. Check the reported counts; a successful command with zero selected tests is not verification. The last command needs a software or hardware graphics adapter supported by the headless renderer (for example Mesa Vulkan on Linux) and writes `crates/ui-egui/spelling-review-light.png` and `crates/ui-egui/spelling-review-dark.png`. Inspect both outputs and attach the resulting images manually to the review with their source SHA; the ignored test is not a new CI job. These synthetic English fixtures check pane geometry and actions, not an interactive Windows session or pixel-perfect Word identity.
|
||||
|
||||
Historical images for `3abe452b7c26f624c09e0143dbee275776821dff` were generated by [run 41220215503](https://github.com/totoz64/wordcraft/actions/runs/41220215503), artifact `13049424116`, and inspected in both themes. They remain evidence for that exact parent SHA; removing the workflow does not relabel them as a render of this follow-up.
|
||||
|
||||
The first light/dark artifacts at `20205a5` were inspected: the sentence frame shrank to its text and the unbounded suggestion list pushed actions below the viewport. The revised layout fills the pane width and scrolls the suggestion list after approximately three cards, keeping the actions below it visible. At `f1264f3`, the revised light/dark previews and full hosted gate passed; Windows and Linux UI checks passed, while macOS remained unvalidated after pre-run cancellations. Both PNGs were inspected, including the right-aligned Read Aloud control. The following review revision gives the exact selected spelling range priority over an overlapping grammar issue and requires new hosted checks. Exact pixel parity is not claimed.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | trivial | Remove the feature-specific preview workflow to follow shared-CI policy; preserve all Rust and tests, document three direct commands and manually attached exact-SHA images; fresh follow-up validation pending |
|
||||
| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `e6a5cd0` after the earlier `eecbb8f` synchronization; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; parent `7c20316` passed hosted checks, fresh evening-SHA validation pending, readiness unchanged |
|
||||
| 2026-10-30 | trivial | Merge the existing pane with main `3b7f7ed`; preserve explicit-position point lookup at the selection focus, implicit/parse-fallback selection priority and correction/undo; new follow-up SHA requires fresh hosted validation |
|
||||
| 2026-10-27 | trivial | Synchronize the existing pane with extracted engine/UI modules on main `436d444`; preserve selection/navigation/undo tests and compact menus, expand workflow path filters; new-SHA validation pending |
|
||||
| 2026-10-26 | minor | Resolve selected spelling before overlapping grammar, add command/pane regressions, include pane-integration workflow paths and distinguish validated history from pending review corrections |
|
||||
| 2026-10-26 | minor | Inspect first successful light/dark artifacts; fill the sentence frame and bound suggestion-list height, retaining all suggestions through scrolling |
|
||||
| 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 |
|
||||
@@ -82,12 +82,20 @@ Aligned copy drags keep their locked document coordinate when middle-button scro
|
||||
|
||||
Labels → New Document with “One label per recipient” includes all filtered/sorted recipients across product-sized sheets (#4880). The last sheet keeps unused labels blank. Fixed text and Single label still produce one sheet. Native Windows interaction and comparison with Microsoft Word remain unverified; this change does not add Update Labels or change Finish & Merge templates.
|
||||
|
||||
## Spelling and grammar correction
|
||||
|
||||
Review › Spelling & Grammar, Home › Editor and F7 open a right-side Proofing task pane. Black-box observation of Word for Windows on 2026-10-26 established the arrangement: issue category, dictionary explanation, sentence context with the issue underlined, a bordered stack of suggestion cards, then Ignore Once, Ignore All and Add to Dictionary links. Clicking a suggestion applies it and advances. The pane uses WordCraft's existing title/docking/close host and its own icons and palette; no Microsoft assets are copied. The suggestion menu exposes supported manual replacement and AutoCorrect actions; Word's synonyms, spelling-out and Change All are not implemented here. No pixel-identical claim is made until the rendered comparison is inspected.
|
||||
|
||||
Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared by all proofing languages; it adds no dictionary or grammar rule. See [spelling-review.md](spelling-review.md) for scope, observation and verification.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | trivial | Keytips: Alt, F badges the Backstage pages with Word's letters and the letter opens the page; Alt, F, P and Alt, F, V open Print; the badges end with the Backstage (#4960) |
|
||||
| 2026-10-31 | trivial | View › Macros opens a Macros dialog (also Alt+F8 and command search): the macros of this session listed with Run, Delete and Record, which reads Stop Recording while a recording runs; the button did nothing before, as `tools.macros` without a macro to run, define or delete only returned the list (#4327) |
|
||||
| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged |
|
||||
| 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged |
|
||||
| 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) |
|
||||
| 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged |
|
||||
| 2026-10-29 | trivial | Insert › Shapes lists the basic shapes as a grid of code-drawn icon tiles with accessible names and tooltips, as Word's gallery does, instead of a text list (#4349) |
|
||||
@@ -97,6 +105,8 @@ Labels → New Document with “One label per recipient” includes all filtered
|
||||
| 2026-10-29 | trivial | Displayed document titles and suggested file names omit NUL characters (X11 panicked on them) and use the localized untitled name when nothing remains (#3634) |
|
||||
| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. |
|
||||
| 2026-10-26 | trivial | Layout › Arrange › Group stays on while a group is selected, so Ungroup in its menu can be reached; it was greyed out because one object has nothing to group (#4229) |
|
||||
| 2026-10-27 | trivial | Port the existing Proofing pane and navigation/selection regressions to extracted engine/UI modules on main `436d444`; preserve compact context menus; new-SHA validation pending, readiness unchanged |
|
||||
| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged |
|
||||
| 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window |
|
||||
| 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window |
|
||||
| 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language |
|
||||
|
||||
Reference in New Issue
Block a user