diff --git a/ROADMAP.md b/ROADMAP.md index 9749c1fbf..133a3ac31 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: table row margins and splitting; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: a failed save from the close prompt says so, #1445; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -157,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page | | 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole | | 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. | +| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -311,6 +312,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) | | 2026-10-13 | trivial | Progress log: space after a page break | | 2026-10-13 | trivial | Progress log: table row margins and splitting | +| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 975ff9012..4537d5ec4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -355,7 +355,8 @@ pub enum Dialog { /// "Do you want to save changes?" before a user's New, Open, Close, Envelopes, Labels or /// Finish & Merge replaces the document (see [`WordApp::run`]); `then` runs once it is /// answered (`ui.saveChanges`), and only while the document it asked about - /// (`Session::document_id`) is still the one open. + /// (`Session::document_id`) is still the one open. `message`: why the last Save failed, shown + /// in the prompt when it comes back after that failure (#1445); empty otherwise. SaveChanges { name: String, then: String, @@ -363,6 +364,8 @@ pub enum Dialog { params: Value, #[serde(skip)] document: u64, + #[serde(default)] + message: String, }, /// Mailings › Select Recipients / Edit Recipient List: a table of recipients to type or /// edit (field names on top), or a file to load instead (#240). @@ -2345,9 +2348,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, tl!("Close")); ok || cancel } - Dialog::SaveChanges { name, .. } => { + Dialog::SaveChanges { name, message, .. } => { ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Do you want to save changes to {name}?"), &[("name", name)])).font(semibold(15.0))); ui.label(tl!("Your changes will be lost if you don't save them.")); + // Why the last Save failed (#1445): the prompt came back so the user can't miss it. + if !message.is_empty() { + ui.add_space(4.0); + ui.label(egui::RichText::new(message.as_str()).color(ui.visuals().error_fg_color)); + } ui.add_space(8.0); let buttons = ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index a8ff95ecf..ff770eac8 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -238,10 +238,9 @@ impl WordApp { r } AfterPick::SaveAs(after) => { - let saved = picked.is_some_and(|path| { - self.run("file.save", json!({"path": path})) - .is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some()) - }); + // The save's error, kept for the Save Changes prompt to explain (#1445). + let result = picked.map(|path| self.run("file.save", json!({"path": path}))); + let saved = matches!(&result, Some(Ok(v)) if v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some()); if saved { self.ui.backstage = false; self.canvas.want_focus = true; @@ -255,6 +254,9 @@ impl WordApp { Ok(json!({"saved": saved})) } AfterSave::Continue { then, params, document } => { + if let Some(Err(error)) = result { + return Err(self.save_failed(&error, then, params, document)); + } if !self.safely_saved(saved) || document != self.session.document_id() { return Ok(json!({"done": false})); } @@ -442,6 +444,24 @@ mod tests { assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() }); } + /// #1445: "Save changes?" → Save on a new document while another file dialog is still open: + /// nothing can be asked, so nothing happens, and the status bar says why. + #[test] + fn save_changes_while_a_file_dialog_is_open_says_so() { + let (mut a, shown) = async_app(); + typed(&mut a); + a.open_dialog(); + assert_eq!(shown.borrow().len(), 1); + a.run("file.close", json!({})).unwrap(); + a.status_msg = None; + let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + assert_eq!(r["done"], false); + assert!(!a.quit_requested); + assert!(a.session.dirty && body_text(&a).contains(UNSAVED)); + let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(msg.contains("file dialog is already open"), "the status bar says why: {msg:?}"); + } + /// "Save changes?" → Save on a new document: the held-back Close runs only once the later /// Save As has actually saved; a cancelled Save As cancels the held-back New. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613eec399..687a0b479 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -651,9 +651,14 @@ impl WordApp { return Ok(json!({"pending": name})); } if self.session.dirty && discards_document(id, ¶ms) { - let name = - self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.title_stem()); - self.dialog = Some(dialogs::Dialog::SaveChanges { name, then: id.to_string(), params, document: self.session.document_id() }); + let name = self.prompt_name(); + self.dialog = Some(dialogs::Dialog::SaveChanges { + name, + then: id.to_string(), + params, + document: self.session.document_id(), + message: String::new(), + }); return Ok(json!({"pending": "saveChanges"})); } // The ribbon and context-menu Paste commands send no text: read the system clipboard's, as @@ -843,7 +848,10 @@ impl WordApp { file_dialogs::Asked::Busy => Ok(json!({"done": false})), }; } - SaveChoice::Save => self.save_for_prompt(), + SaveChoice::Save => match self.save_for_prompt() { + Ok(saved) => saved, + Err(error) => return Err(self.save_failed(&error, then, params, document)), + }, }; if !go { return Ok(json!({"done": false})); @@ -870,13 +878,33 @@ impl WordApp { /// Save before New/Open/Close (and the mailings that replace the document): true once the /// document is safely written. A save in a format that doesn't keep everything (a page /// image, plain text) writes a copy and leaves the document unsaved, so it doesn't count: - /// the command is cancelled and the document stays. - fn save_for_prompt(&mut self) -> bool { - let saved = match self.execute("file.save", json!({})) { - Ok(v) => v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some(), - Err(_) => false, - }; - self.safely_saved(saved) + /// the command is cancelled and the document stays. A save that fails (no permission, the + /// file locked by another app, a missing folder) is the save's error ([`Self::save_failed`] + /// tells the user). + fn save_for_prompt(&mut self) -> Result { + let v = self.execute("file.save", json!({}))?; + let saved = v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some(); + Ok(self.safely_saved(saved)) + } + + /// The document's name as the Save Changes prompt shows it: its file's name, else its title. + fn prompt_name(&self) -> String { + self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.title_stem()) + } + + /// Save (or Save As) from the Save Changes prompt failed (#1445): say so plainly on the status + /// bar and in the prompt, which comes back for Save again, Don't Save or Cancel, with the + /// held-back command (`then`) still waiting. Nothing closes and nothing is discarded. Returns + /// the message, the prompt answer's error. + pub(crate) fn save_failed(&mut self, error: &str, then: String, params: Value, document: u64) -> String { + let name = self.prompt_name(); + let message = i18n::fmt(tl!("Couldn't save {name}, so it is still open and nothing was lost: {error}"), &[("name", &name), ("error", error)]); + log::error!("save from the Save Changes prompt failed: {error}"); + self.status(message.clone()); + if document == self.session.document_id() { + self.dialog = Some(dialogs::Dialog::SaveChanges { name, then, params, document, message: message.clone() }); + } + message } /// Whether a save before New/Open/Close counts: it happened, and kept everything. @@ -2948,6 +2976,14 @@ mod tests { a.session.doc.plain_text(wordcraft_doc::StoryRef::Body) } + /// The reason the Save Changes prompt shows (a failed save), if that prompt is open. + fn prompt_message(a: &WordApp) -> Option { + match &a.dialog { + Some(dialogs::Dialog::SaveChanges { message, .. }) => Some(message.clone()), + _ => None, + } + } + fn prompt(a: &WordApp) -> Option<&'static str> { a.dialog.as_ref().map(|d| d.name()) } @@ -3091,6 +3127,114 @@ mod tests { assert!(body_text(&a).contains(UNSAVED), "nothing was replaced on the way out"); } + /// #1445: the window's X on an edited document with a file → Save writes the file and lets + /// the window close. + #[test] + fn window_close_save_writes_the_file_and_quits() { + let dir = scratch("close-save"); + let path = docx_on_disk(&dir, "Draft"); + let mut a = app(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + assert!(!a.close_requested()); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + let on_disk = wordcraft_engine::io::open_path(&path).unwrap().plain_text(wordcraft_doc::StoryRef::Body); + assert!(on_disk.contains("Revised Draft"), "the edit is on disk: {on_disk:?}"); + assert!(a.quit_requested, "the app may close"); + assert!(a.close_requested(), "the close that follows goes ahead"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1445: when Save in the close prompt can't write the file, the prompt's answer is an error + /// that says so plainly, the status bar shows it, and the document stays open, still edited. + #[test] + fn window_close_save_that_fails_says_so_and_keeps_the_document() { + let dir = scratch("close-save-fails"); + let folder = dir.join("folder"); + std::fs::create_dir_all(&folder).unwrap(); + let path = docx_on_disk(&folder, "Draft"); + let mut a = app(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + // The document's folder is gone and a file has its name: nothing can be written there. + std::fs::remove_dir_all(&folder).unwrap(); + std::fs::write(&folder, b"not a folder").unwrap(); + assert!(!a.close_requested()); + a.status_msg = None; + let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err(); + assert!(err.contains("Couldn't save contract.docx") && err.contains("still open"), "{err}"); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| *m == err), "the status bar says why: {:?}", a.status_msg); + assert!(!a.quit_requested, "nothing closes"); + assert!(a.session.dirty && body_text(&a).contains("Revised Draft"), "nothing was discarded"); + assert_eq!(a.session.path.as_deref(), Some(path.as_path())); + // The prompt comes back with the reason in it: Save again, Don't Save or Cancel. + assert_eq!(prompt(&a), Some("saveChanges"), "the prompt is open again"); + assert_eq!(prompt_message(&a).as_deref(), Some(err.as_str()), "the prompt says why"); + assert!(a.run("ui.saveChanges", json!({"answer": "save"})).is_err(), "Save again fails again"); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + assert_eq!(prompt(&a), None); + assert!(!a.close_requested(), "the next close asks again"); + assert_eq!(prompt_message(&a).as_deref(), Some(""), "a fresh prompt, no old reason"); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested, "Don't Save still closes"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1445: a new document → close → Save → Save As to a place that can't be written: the + /// same plain error, the prompt back with the reason, nothing closed or discarded. + #[test] + fn window_close_save_as_that_fails_says_so_and_keeps_the_document() { + let dir = scratch("close-save-as-fails"); + let blocker = dir.join("folder"); + std::fs::write(&blocker, b"not a folder").unwrap(); + let picked = blocker.join("novel.docx").to_string_lossy().to_string(); + let mut a = typed(); + a.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + assert!(!a.close_requested()); + a.status_msg = None; + let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err(); + assert!(err.contains("Couldn't save") && err.contains("still open"), "{err}"); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| *m == err), "the status bar says why: {:?}", a.status_msg); + assert!(!a.quit_requested, "nothing closes"); + assert!(a.session.dirty && body_text(&a).contains(UNSAVED), "nothing was discarded"); + assert_eq!(a.session.path, None); + assert_eq!(prompt(&a), Some("saveChanges"), "the prompt is open again"); + assert_eq!(prompt_message(&a).as_deref(), Some(err.as_str()), "the prompt says why"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1445: the prompt that comes back after a failed save shows the reason on screen. + #[test] + fn save_prompt_shows_why_the_save_failed() { + use egui_kittest::kittest::Queryable; + let mut a = typed(); + a.run("file.close", json!({})).unwrap(); + let reason = "Couldn't save Draft, so it is still open and nothing was lost: disk full"; + if let Some(dialogs::Dialog::SaveChanges { message, .. }) = &mut a.dialog { + *message = reason.into(); + } + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().build_ui_state( + move |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + if fonts { + dialogs::show(app, &ctx); + } else { + theme::install_fonts_for(&ctx, false); + fonts = true; + } + }, + a, + ); + for _ in 0..3 { + h.step(); + } + assert!(h.query_by_label(reason).is_some(), "the reason is in the prompt"); + assert!(h.query_by_label("Save").is_some(), "Save again is offered"); + } + /// Enter activates the focused prompt button; it means Save only when no button has focus. /// (Enter on a focused Cancel or Don't Save used to be overridden by a blanket Enter → Save.) #[test] @@ -3799,8 +3943,9 @@ mod tests { a.run("file.new", json!({})).unwrap(); a.status_msg = None; - let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - assert_eq!(r["done"], false, "the New was cancelled"); + // A failed save is an error, not a quiet Cancel (#1445); the New doesn't run. + let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err(); + assert!(err.contains("still open") && err.contains("blocked by the browser"), "{err}"); assert!(body_text(&a).contains(UNSAVED)); assert!(a.session.dirty); let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();