mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 00:40:33 -06:00
* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Dmitrii Khlapov
Brandon Thomas
parent
17ec132c7c
commit
f2a96a21f9
+3
-1
@@ -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) |
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+158
-13
@@ -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<bool, String> {
|
||||
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<String> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user