diff --git a/ROADMAP.md b/ROADMAP.md index 88bc27fe3..39675b4fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -191,6 +191,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-17 | SECTIONPAGES includes shared continuous-section pages. A native original control confirms a section starting on page 1 and continuing on page 2 reports 2. | | 2026-10-17 | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` | | 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-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. | | 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. | @@ -523,6 +524,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-17 | trivial | Progress log: Read Aloud speaks each sentence in its own language, #1843 | | 2026-10-18 | trivial | Progress log: `wordcraft-cli run --script` (#1963) | | 2026-10-18 | trivial | Progress log: header-cell picture watermarks drawn behind the text (#1775); readiness estimates unchanged | +| 2026-10-14 | trivial | Progress log: comment drafts kept and written before saving or closing (#1348) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) | | 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) | diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 78cf93dec..904bd8ddc 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -12,8 +12,10 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub fn specs() -> Vec { vec![ CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key(super::mac_or("Mod+Alt+A / Mod+Alt+M", "Mod+Alt+M")).params(r#"{"text": string}"#), - CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#), - CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment).params(r#"{"id": n, "text": string}"#), + CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#).when(comments_change), + CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment) + .params(r#"{"id": n, "text": string}"#) + .when(comments_change), CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: n, "all"?: bool}"#), CommandSpec::new("review.resolveComment", "Resolve", "Review › Comments", |s, v| { let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("`id` required".into()))?; @@ -166,6 +168,15 @@ fn comment_id(s: &Session) -> Result { wordcraft_doc::free_id(ids.keys().next_back().copied(), ids.keys().copied(), 0).ok_or_else(|| CmdError::Failed("no free comment id".into())) } +/// Comments can't be written or edited in a document protected as read only or for filling in +/// forms (the comment fields are read-only there, #1348). +fn comments_change(s: &Session) -> Option<&'static str> { + match s.doc.settings.protection.as_deref() { + Some("readOnly" | "forms") => Some("the document is protected"), + _ => None, + } +} + /// Replace a comment's (or reply's) text; each line becomes a paragraph. fn edit_comment(s: &mut Session, v: &Value) -> CmdResult { let id = p::u64(v, "id").and_then(|x| u32::try_from(x).ok()).ok_or_else(|| CmdError::Params("`id` required".into()))?; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d50018f03..5deb40a13 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1598,7 +1598,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { && resp.contains_pointer() && !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| over_balloon(app, p)) { - deselect_balloon(app, ui.ctx()); + deselect_balloon(app); } // Space and Enter also set `Response::clicked` on a focused click-sensing widget // (egui's fake primary click). `request_focus` always interrupts IME; on X11 that @@ -1781,7 +1781,7 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re let mw = markup_width(app); let float = floating_comments(app); if app.canvas.balloon.is_some_and(|id| (mw <= 0.0 && !float) || !app.session.doc.comments.contains_key(&id)) { - deselect_balloon(app, ui.ctx()); + deselect_balloon(app); } if mw <= 0.0 && !float { return; @@ -1974,7 +1974,7 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re } } if let Some((id, p)) = clicked { - select_balloon(app, ui.ctx(), id); + select_balloon(app, id); app.session.sel = wordcraft_engine::Selection::caret(p); } } @@ -2160,7 +2160,7 @@ fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u let _ = app.run("review.resolveComment", json!({"id": id})); } if delete { - deselect_balloon(app, ui.ctx()); + deselect_balloon(app); let _ = app.run("review.deleteComment", json!({"id": id})); } card @@ -2168,11 +2168,11 @@ fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u /// Select a comment's balloon (its text gets the keyboard), writing back any edit in the one /// selected before. -pub fn select_balloon(app: &mut WordApp, ctx: &egui::Context, id: u32) { +pub fn select_balloon(app: &mut WordApp, id: u32) { if app.canvas.balloon == Some(id) { return; } - deselect_balloon(app, ctx); + deselect_balloon(app); app.canvas.balloon = Some(id); app.canvas.balloon_focus = true; app.canvas.balloon_h = 0.0; @@ -2185,16 +2185,17 @@ fn over_balloon(app: &WordApp, p: Pos2) -> bool { /// Leave the selected balloon, writing back what was typed in it (its fields may not be drawn /// again to notice they lost focus). -pub fn deselect_balloon(app: &mut WordApp, ctx: &egui::Context) { +pub fn deselect_balloon(app: &mut WordApp) { let Some(id) = app.canvas.balloon.take() else { return }; app.canvas.balloon_reply = false; app.canvas.balloon_focus = false; let thread: Vec = std::iter::once(id).chain(app.session.doc.comments.iter().filter(|(_, c)| c.parent == Some(id)).map(|(k, _)| *k)).collect(); for c in thread { - crate::panes::commit_comment(app, ctx, c); + // A refusal is on the status line; the draft stays for when the balloon is selected again. + let _ = crate::panes::commit_comment(app, c); } - crate::panes::commit_reply(app, ctx, id); + let _ = crate::panes::commit_reply(app, id); } fn dashed(p: &egui::Painter, a: Pos2, b: Pos2, s: Stroke) { @@ -4171,10 +4172,7 @@ mod tests { h.step(); } assert_eq!(h.state().session.undo_depth(), depth, "nothing is written while typing"); - h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); - for _ in 0..3 { - h.step(); - } + leave_field(&mut h); let part = h.state().session.doc.comments[&id].part; let text = h.state().session.doc.plain_text(StoryRef::Part(part)); assert!(text.contains("Edited twice") && text.contains("Original"), "{text:?}"); @@ -4297,6 +4295,237 @@ mod tests { assert_eq!(h.state_mut().session.layout().pages[0].header_story.map(StoryRef::Part), Some(first)); } + /// #513: right-clicking a picture selects it and offers Wrap Text, as in Word; choosing + /// Square there wraps text around it. + /// #1348: an app with one comment ("Original") on some text, its balloon on screen. + fn comment_harness() -> (egui_kittest::Harness<'static, WordApp>, u32) { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "Some text here"})).unwrap(); + a.run("select.text", json!({"text": "text"})).unwrap(); + let id = a.run("review.newComment", json!({"text": "Original"})).unwrap()["id"].as_u64().unwrap() as u32; + a.run("view.commentsPane", json!({"value": false})).unwrap(); + 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(); + } + (h, id) + } + + /// Click comment `id`'s balloon (from no selected balloon), so its text has the keyboard. + fn click_balloon(h: &mut egui_kittest::Harness<'static, WordApp>, id: u32) { + h.state_mut().canvas.balloon = None; + for _ in 0..2 { + h.step(); + } + let card = h.state().canvas.balloon_rects.iter().find(|(i, _)| *i == id).map(|(_, r)| *r).expect("the comment has a balloon"); + let at = card.left_top() + vec2(30.0, 8.0); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + for _ in 0..3 { + h.step(); + } + assert_eq!(h.state().canvas.balloon, Some(id), "the click selected the balloon"); + } + + fn type_in(h: &mut egui_kittest::Harness<'static, WordApp>, text: &str) { + h.event(egui::Event::Text(text.into())); + h.step(); + } + + /// Leave the comment field by clicking on the page (which also leaves the balloon). + fn leave_field(h: &mut egui_kittest::Harness<'static, WordApp>) { + let at = h.state().canvas.page_rects[0].center(); + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + for _ in 0..3 { + h.step(); + } + } + + fn press_escape(h: &mut egui_kittest::Harness<'static, WordApp>) { + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + for _ in 0..3 { + h.step(); + } + } + + fn comment_text(app: &WordApp, id: u32) -> String { + let part = app.session.doc.comments[&id].part; + app.session.doc.plain_text(StoryRef::Part(part)) + } + + /// #1348: an edit the document refuses (here read-only protection) keeps what was typed and + /// says why; once the edit is allowed, leaving the field again writes it. + #[test] + fn a_refused_comment_edit_keeps_the_draft() { + let (mut h, id) = comment_harness(); + click_balloon(&mut h, id); + type_in(&mut h, "Edited"); + h.state_mut().session.doc.settings.protection = Some("readOnly".into()); + leave_field(&mut h); + assert_eq!(comment_text(h.state(), id), "Original", "the protected document refused the edit"); + let status = h.state().status_msg.as_ref().map(|(s, _)| s.clone()).unwrap_or_default(); + assert!(status.contains("protected"), "the refusal is shown: {status:?}"); + h.state_mut().session.doc.settings.protection = None; + click_balloon(&mut h, id); + leave_field(&mut h); + let text = comment_text(h.state(), id); + assert!(text.contains("Edited"), "the typed text survived the refusal: {text:?}"); + } + + /// #1348: a draft typed in one document never lands in another document's comment that happens + /// to have the same id. + #[test] + fn a_comment_draft_stays_with_its_document() { + let (mut h, id) = comment_harness(); + click_balloon(&mut h, id); + type_in(&mut h, " stale"); + // An agent replaces the document while the balloon is still being typed in. + h.state_mut().session.set_document(wordcraft_doc::Document::new()); + let a = h.state_mut(); + a.run("text.insert", json!({"text": "Other words here"})).unwrap(); + a.run("select.text", json!({"text": "words"})).unwrap(); + let fresh = a.run("review.newComment", json!({"text": "Fresh"})).unwrap()["id"].as_u64().unwrap() as u32; + assert_eq!(fresh, id, "the new document reuses the comment id"); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + for _ in 0..3 { + h.step(); + } + click_balloon(&mut h, fresh); + leave_field(&mut h); + assert_eq!(comment_text(h.state(), fresh), "Fresh", "the old document's draft didn't leak in"); + } + + /// #1348: saving while a comment is still being typed in saves what was typed. + #[test] + fn saving_writes_a_pending_comment_draft_first() { + let (mut h, id) = comment_harness(); + click_balloon(&mut h, id); + type_in(&mut h, "Typed "); + let path = std::env::temp_dir().join(format!("wordcraft-1348-save-{}.docx", std::process::id())); + h.state_mut().run("file.save", json!({"path": path.to_string_lossy()})).unwrap(); + let text = comment_text(h.state(), id); + assert!(text.contains("Typed"), "the draft was written before saving: {text:?}"); + assert!(!h.state().session.dirty, "and saved"); + let saved = wordcraft_engine::io::open_path(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let part = saved.comments.values().next().expect("the comment was saved").part; + assert!(saved.plain_text(StoryRef::Part(part)).contains("Typed"), "the file has the typed text"); + } + + /// #1348: closing a document whose only change is a comment still being typed asks to save. + #[test] + fn closing_with_a_pending_comment_draft_asks_to_save() { + let (mut h, id) = comment_harness(); + h.state_mut().session.dirty = false; + click_balloon(&mut h, id); + type_in(&mut h, "Typed "); + assert!(!h.state_mut().close_requested(), "the typed comment is an unsaved change"); + assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); + } + + /// #1348: an agent can't replace the document while comment text is still being typed + /// unless it passes `force`, as with unsaved changes. + #[test] + fn an_agent_cannot_replace_the_document_over_a_pending_comment_draft() { + let (mut h, id) = comment_harness(); + h.state_mut().session.dirty = false; + click_balloon(&mut h, id); + type_in(&mut h, "Typed "); + let open = json!({"path": "/nonexistent/other.docx"}); + assert!(crate::control::close_refused(h.state(), "file.open", &open).is_some()); + assert!(crate::control::close_refused(h.state(), "file.new", &json!({})).is_some()); + assert!(crate::control::close_refused(h.state(), "file.close", &json!({})).is_some()); + assert!(crate::control::close_refused(h.state(), "file.close", &json!({"force": true})).is_none()); + assert!(crate::control::close_refused(h.state(), "text.insert", &json!({"text": "x"})).is_none()); + } + + /// #1348: a comment edit typed, then refused (the document became read-only). + fn refused_draft() -> (egui_kittest::Harness<'static, WordApp>, u32) { + let (mut h, id) = comment_harness(); + h.state_mut().session.dirty = false; + click_balloon(&mut h, id); + type_in(&mut h, "Refused "); + h.state_mut().session.doc.settings.protection = Some("readOnly".into()); + (h, id) + } + + /// #1348: a draft the document refuses doesn't keep the window open: closing asks to save, + /// and Don't Save drops the draft and closes. + #[test] + fn a_refused_comment_draft_can_be_dropped_on_close() { + let (mut h, _) = refused_draft(); + assert!(!h.state_mut().close_requested(), "the refused draft is an unsaved change"); + assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "the save prompt asks about it"); + h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(h.state_mut().close_requested(), "Don't Save closes"); + } + + /// #1348: New with a refused draft asks to save; Don't Save makes the new document. + #[test] + fn a_refused_comment_draft_can_be_dropped_on_new() { + let (mut h, _) = refused_draft(); + let document = h.state().session.document_id(); + h.state_mut().run("file.new", json!({})).unwrap(); + assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "the save prompt asks about it"); + h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_ne!(h.state().session.document_id(), document, "Don't Save made the new document"); + } + + /// #1348: Save with a refused draft saves the rest of the document, keeps the draft and says why. + #[test] + fn saving_with_a_refused_comment_draft_saves_the_document() { + let (mut h, id) = refused_draft(); + h.state_mut().session.dirty = true; + let path = std::env::temp_dir().join(format!("wordcraft-1348-refused-{}.docx", std::process::id())); + let r = h.state_mut().run("file.save", json!({"path": path.to_string_lossy()})); + let saved = wordcraft_engine::io::open_path(&path); + let _ = std::fs::remove_file(&path); + assert!(r.is_ok(), "{r:?}"); + assert!(saved.is_ok(), "the document was saved"); + assert!(!h.state().session.dirty); + assert!(crate::panes::comment_draft(h.state(), id).is_some_and(|d| d.contains("Refused")), "the draft stays"); + let status = h.state().status_msg.as_ref().map(|(s, _)| s.clone()).unwrap_or_default(); + assert!(status.contains("protected"), "the refusal is shown: {status:?}"); + } + + /// #1348: in a read-only document the comment fields can't be typed in. + #[test] + fn comment_fields_are_read_only_where_comments_cant_change() { + let (mut h, id) = comment_harness(); + h.state_mut().session.doc.settings.protection = Some("readOnly".into()); + h.step(); + click_balloon(&mut h, id); + type_in(&mut h, "Typed "); + assert_eq!(crate::panes::comment_draft(h.state(), id), None, "nothing was typed"); + assert_eq!(comment_text(h.state(), id), "Original"); + } + + /// #1348: Escape in a comment field throws the draft away and shows the comment's text again. + #[test] + fn escape_reverts_a_comment_draft() { + let (mut h, id) = comment_harness(); + click_balloon(&mut h, id); + type_in(&mut h, "Discarded "); + press_escape(&mut h); + assert_eq!(crate::panes::comment_draft(h.state(), id), None, "the draft is gone"); + assert_eq!(comment_text(h.state(), id), "Original", "nothing was written"); + leave_field(&mut h); + assert_eq!(comment_text(h.state(), id), "Original", "and nothing is written later"); + } + /// #513: right-clicking a picture selects it and offers Wrap Text, as in Word; choosing /// Square there wraps text around it. #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bcd883a6e..49ff81f88 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -109,8 +109,15 @@ fn click_events(app: &mut WordApp, pos: egui::Pos2, button: egui::PointerButton, /// save first, or pass `"force": true` (only a real boolean true counts) to discard it (#1268). pub(crate) fn close_refused(app: &WordApp, id: &str, params: &Value) -> Option<&'static str> { let force = params.get("force").and_then(Value::as_bool) == Some(true); - (id == "file.close" && app.session.dirty && !force) - .then_some("the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close") + if force { + return None; + } + if id == "file.close" && app.session.dirty { + return Some("the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close"); + } + // Comment text still being typed is unsaved work too (#1348): replacing the document would drop it. + (crate::discards_document(id, params) && crate::panes::has_comment_drafts(app)) + .then_some("a comment is still being edited: finish it first, or pass {\"force\": true} to discard it") } /// The reply to a request that needs a document while none is open (#1129). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a42d2beec..6bef6ea19 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -621,6 +621,8 @@ pub struct WordApp { /// the session's document, no document is open: the window shows no page and only the File /// tab, Help and settings work. See [`WordApp::has_document`]. no_document: Option, + /// Comment and reply text typed but not yet written to the document (#1348). + pub(crate) comment_drafts: panes::CommentDrafts, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -717,6 +719,7 @@ impl WordApp { info_editing: None, recovery_seen: false, no_document: None, + comment_drafts: Default::default(), } } @@ -841,6 +844,11 @@ impl WordApp { /// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask /// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`). pub fn run(&mut self, id: &str, params: Value) -> Result { + // Comment text still being typed is written first, so a save or export has it and a + // close, New or Open counts it as a change (#1348). A draft the document refuses is not + // part of it: saving and exporting go on without it (the refusal stays on the status + // line), and replacing the document asks Save / Don't Save / Cancel about it. + let refused = if flushes_comment_drafts(id, ¶ms) { panes::flush_comment_drafts(self).err() } else { None }; if id == "file.recover" && params.as_object().is_none_or(|p| p.is_empty()) { self.ui.backstage = true; self.ui.backstage_page = "recover".into(); @@ -852,7 +860,7 @@ impl WordApp { self.dialog = dialogs::Dialog::open(name, self); return Ok(json!({"pending": name})); } - if (self.session.dirty || self.save_unconfirmed()) && discards_document(id, ¶ms) { + if (self.session.dirty || self.save_unconfirmed() || refused.is_some()) && discards_document(id, ¶ms) { let name = self.prompt_name(); self.dialog = Some(dialogs::Dialog::SaveChanges { name, @@ -880,6 +888,10 @@ impl WordApp { } let applied = applied_font(id, ¶ms); let r = self.execute_user(id, params); + // A save error already on the status line is the reason to see; the refusal shows otherwise. + if let (Some(e), true) = (refused, r.is_ok()) { + self.status(e); + } if let (Ok(_), Some(name)) = (&r, applied) { push_recent_font(&mut self.ui.recent_fonts, &name); } @@ -1284,8 +1296,16 @@ impl WordApp { self.status(tl!("Wait for the print job to finish submitting before closing.")); return false; } + if self.quit_requested { + return true; + } // No document open (#1129): nothing to save. - if self.quit_requested || !self.session.dirty || !self.has_document() { + if !self.has_document() { + return true; + } + // A comment draft the document refuses is an unsaved change: the prompt can drop it. + let refused = panes::flush_comment_drafts(self).is_err(); + if !self.session.dirty && !self.save_unconfirmed() && !refused { return true; } // `quit`: once the prompt is answered the window closes too, not just the document. @@ -2075,7 +2095,7 @@ impl WordApp { /// Tell the host whether the document has unsaved changes ([`Services::on_dirty`]). fn report_dirty(&self) { if let Some(f) = &self.services.on_dirty { - f(self.session.dirty); + f(self.session.dirty || panes::has_comment_drafts(self)); } } @@ -2597,11 +2617,30 @@ pub(crate) fn works_without_document(id: &str) -> bool { ) || id.starts_with("help.") } +/// Commands that save, export, print, send or replace the document: comment text still being +/// typed is written before them (#1348). +fn flushes_comment_drafts(id: &str, params: &Value) -> bool { + matches!( + id, + "file.save" + | "file.saveAs" + | "file.saveTemplate" + | "file.open" + | "file.exportPdf" + | "file.exportPng" + | "file.print" + | "file.printPdf" + | "file.share" + | "ui.print" + | "ui.printSubmit" + ) || discards_document(id, params) +} + /// User commands that replace or close the document (`file.open` without a path only shows the /// file picker; the open that follows is checked). Envelopes, Labels and Finish & Merge make a /// new document in its place; a merge written to a file (`path`) and an envelope added to this /// document (`add`) leave it alone. -fn discards_document(id: &str, params: &Value) -> bool { +pub(crate) fn discards_document(id: &str, params: &Value) -> bool { match id { "file.new" | "file.close" | "mailings.labels" => true, "mailings.envelopes" => params.get("add").and_then(Value::as_bool) != Some(true), diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index e680aecbd..c600daaea 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -2,6 +2,8 @@ //! Accessibility and Alt Text (see `a11y_pane`), Restrict Editing ([`crate::dialogs_review`]), Comments, //! and the SmartArt Text Pane. +use std::collections::BTreeMap; + use egui::{Stroke, Ui, vec2}; use serde_json::{Value, json}; use wordcraft_doc::{Pos, StoryRef}; @@ -414,12 +416,46 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { }); } -fn edit_key(id: u32) -> egui::Id { - egui::Id::new(("comment_edit", id)) +/// What has been typed in comment and reply fields but not yet written to the document (#1348). +/// Drafts belong to one document ([`Session::document_id`](wordcraft_engine::Session::document_id)): +/// when another document replaces it they are dropped, so a comment there with the same id never +/// shows them. A draft is removed only once its command succeeds; a refused edit keeps it. +#[derive(Default)] +pub(crate) struct CommentDrafts { + document: u64, + edits: BTreeMap, + replies: BTreeMap, } -fn reply_key(id: u32) -> egui::Id { - egui::Id::new(("comment_reply", id)) +impl CommentDrafts { + /// The drafts for `document`, dropping any left from another one. + fn of(&mut self, document: u64) -> &mut Self { + if self.document != document { + self.edits.clear(); + self.replies.clear(); + self.document = document; + } + self + } +} + +/// The pending draft of comment `id`'s text in the open document, if any. +#[cfg(test)] +pub(crate) fn comment_draft(app: &WordApp, id: u32) -> Option { + let d = &app.comment_drafts; + if d.document == app.session.document_id() { d.edits.get(&id).cloned() } else { None } +} + +/// Whether the open document has a comment or reply draft not yet written to it (typed, or +/// kept after the document refused it): closing the page would lose it. +pub(crate) fn has_comment_drafts(app: &WordApp) -> bool { + let d = &app.comment_drafts; + d.document == app.session.document_id() && !(d.edits.is_empty() && d.replies.is_empty()) +} + +fn drafts(app: &mut WordApp) -> &mut CommentDrafts { + let document = app.session.document_id(); + app.comment_drafts.of(document) } /// A comment's text, editable in place (the Comments pane and the balloons). Typing stays in a @@ -427,9 +463,16 @@ fn reply_key(id: u32) -> egui::Id { /// `review.editComment`, so each editing session is one undo step. pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option, focus: bool) { let Some(part) = app.session.doc.comments.get(&id).map(|c| c.part) else { return }; - let key = edit_key(id); - let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| app.session.doc.plain_text(StoryRef::Part(part))); - let mut edit = egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY).hint_text(tl!("Add a comment")); + let draft = drafts(app).edits.get(&id).cloned(); + let mut text = draft.unwrap_or_else(|| app.session.doc.plain_text(StoryRef::Part(part))); + // Read-only where comments can't change (a protected document), so nothing is typed in vain. + let editable = crate::widgets::enabled(app, "review.editComment"); + let mut edit = egui::TextEdit::multiline(&mut text) + .id(field_id(app, ui, "comment_edit", id)) + .interactive(editable) + .desired_rows(1) + .desired_width(f32::INFINITY) + .hint_text(tl!("Add a comment")); if let Some(f) = font { edit = edit.font(f); } @@ -438,20 +481,36 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti r.request_focus(); } if r.changed() { - ui.data_mut(|d| d.insert_temp(key, text)); + drafts(app).edits.insert(id, text); } if r.lost_focus() { - commit_comment(app, ui.ctx(), id); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Escape throws the draft away: the field shows the comment's text again. + drafts(app).edits.remove(&id); + } else { + // A refusal is on the status line, and the draft stays in the field. + let _ = commit_comment(app, id); + } } } -/// Write a comment's draft text back, when it has one that differs from the comment. -pub(crate) fn commit_comment(app: &mut WordApp, ctx: &egui::Context, id: u32) { - let Some(text) = ctx.data_mut(|d| d.remove_temp::(edit_key(id))) else { return }; - let Some(part) = app.session.doc.comments.get(&id).map(|c| c.part) else { return }; - if text != app.session.doc.plain_text(StoryRef::Part(part)) { - let _ = app.run("review.editComment", json!({"id": id, "text": text})); +/// A comment field's widget id, per document: egui's own state for the field (its cursor and +/// undo history) never carries over to another document's comment with the same id. +fn field_id(app: &WordApp, ui: &Ui, kind: &str, id: u32) -> egui::Id { + ui.id().with((kind, app.session.document_id(), id)) +} + +/// Write a comment's draft text back, when it has one that differs from the comment. The draft +/// is dropped once written (or when there is nothing to write); a refused edit keeps it. +pub(crate) fn commit_comment(app: &mut WordApp, id: u32) -> Result<(), String> { + let Some(text) = drafts(app).edits.get(&id).cloned() else { return Ok(()) }; + if let Some(part) = app.session.doc.comments.get(&id).map(|c| c.part) + && text != app.session.doc.plain_text(StoryRef::Part(part)) + { + app.run("review.editComment", json!({"id": id, "text": text}))?; } + drafts(app).edits.remove(&id); + Ok(()) } /// The comment a reply to `id` answers: replies to a reply join its thread. @@ -463,11 +522,17 @@ fn thread_root(app: &WordApp, id: u32) -> u32 { } /// A field for replying to a comment. When it loses focus a non-empty reply is posted with -/// `review.reply`; returns true when the field is done (posted or left empty). +/// `review.reply`; returns true when the field is done (posted or left empty). A refused reply +/// keeps the field open with its text. pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option, focus: bool) -> bool { - let key = reply_key(id); - let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_default(); - let mut edit = egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY).hint_text(tl!("Write a reply")); + let mut text = drafts(app).replies.get(&id).cloned().unwrap_or_default(); + let editable = crate::widgets::enabled(app, "review.reply"); + let mut edit = egui::TextEdit::multiline(&mut text) + .id(field_id(app, ui, "comment_reply", id)) + .interactive(editable) + .desired_rows(1) + .desired_width(f32::INFINITY) + .hint_text(tl!("Write a reply")); if let Some(f) = font { edit = edit.font(f); } @@ -476,22 +541,50 @@ pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option r.request_focus(); } if r.changed() { - ui.data_mut(|d| d.insert_temp(key, text)); + drafts(app).replies.insert(id, text); } - if r.lost_focus() { - commit_reply(app, ui.ctx(), id); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Escape throws the reply away and closes the field. + drafts(app).replies.remove(&id); return true; } - false + r.lost_focus() && commit_reply(app, id).is_ok() } -/// Post a pending reply draft to comment `id` (nothing when there is none or it is blank). -pub(crate) fn commit_reply(app: &mut WordApp, ctx: &egui::Context, id: u32) { - let Some(text) = ctx.data_mut(|d| d.remove_temp::(reply_key(id))) else { return }; - if !text.trim().is_empty() { +/// Post a pending reply draft to comment `id` (nothing when there is none or it is blank). The +/// draft is dropped once posted; a refused reply keeps it. +pub(crate) fn commit_reply(app: &mut WordApp, id: u32) -> Result<(), String> { + let Some(text) = drafts(app).replies.get(&id).cloned() else { return Ok(()) }; + if !text.trim().is_empty() && app.session.doc.comments.contains_key(&id) { let root = thread_root(app, id); - let _ = app.run("review.reply", json!({"id": root, "text": text.trim_end()})); + app.run("review.reply", json!({"id": root, "text": text.trim_end()}))?; } + drafts(app).replies.remove(&id); + Ok(()) +} + +/// Write every pending comment and reply draft to the document, so a save has them and a close, +/// New or Open sees them as unsaved changes (#1348). Every draft is tried; the first refusal is +/// returned (and is on the status line), and refused drafts stay. +pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> { + let (edits, replies): (Vec, Vec) = { + let d = drafts(app); + (d.edits.keys().copied().collect(), d.replies.keys().copied().collect()) + }; + let mut first = Ok(()); + for id in edits { + let r = commit_comment(app, id); + if first.is_ok() { + first = r; + } + } + for id in replies { + let r = commit_reply(app, id); + if first.is_ok() { + first = r; + } + } + first } /// Style Inspector: the paragraph and character levels at the caret, each with its style and the diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0f39512a4..9fda19c32 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -32,6 +32,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Modal dialogs | **60** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | +| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments (typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | @@ -85,6 +86,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-16 | trivial | Update Field is offered on a table of contents after it was saved and reopened (a range field then), including at its first and last character; repeated save and update no longer adds a blank line (#2182; the flow in that report already worked since #1197) | | 2026-10-18 | trivial | Different First Page: double-clicking page 1's header while editing another one goes into page 1's own; Insert › Header/Footer/Page Number and Edit Header/Footer use the header the caret's page shows (first, even or default) instead of always the default (#2826) | | 2026-10-15 | trivial | Track Changes Options › Moves: Moved from and Moved to rows (mark and colour; green double strikethrough and double underline by default, as in Word for Mac, which has no Track moves checkbox). `review.trackingOptions {"moveFromMark", "moveFromColor", "moveToMark", "moveToColor"}`, saved per user; `"trackMoves"` sets the document's setting. Mark lists in Word for Mac's order; Deletions and Moved from gain (none), Bold, Italic and Double underline; moved text marked (none) is plain text | +| 2026-10-14 | trivial | Comments pane and balloons: a comment or reply edit the document refuses keeps the typed text and shows why; drafts belong to their document, so a new or opened one never shows them; Save, Export, Print, Close, New and Open write pending drafts first; a refused draft doesn't block them (Save saves the rest, Close/New/Open ask Save / Don't Save / Cancel about it); Escape discards a draft; comment and reply fields are read-only in a document protected as read only or for forms (#1348) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-16 | trivial | Create Source: automatic Tag like Word's (`Smi20`, `Kim001`); Edit Source keeps the Tag and citations show the new Author and Year (#2094; Word for Mac 16.113.4, user-checked) | | 2026-10-18 | trivial | Split Cells on a merged cell: non-divisor row counts refused with a divisor message, 1 × 1 on a merged cell does nothing (#2592 follow-up) |