From 81b5602bf0919e8d5f3cad80300805d01f3565bf Mon Sep 17 00:00:00 2001 From: Nathanael Date: Thu, 15 Oct 2026 06:31:55 +0700 Subject: [PATCH] insert.picture and insert.icon leave the caret after the picture (#965) (#1437) * contributors: add Nael-Nathanael Co-Authored-By: Claude Opus 5.5 (1M context) * insert.picture and insert.icon leave the caret after the picture (#965) A script that typed right after inserting a picture replaced it, so the picture was missing from the saved file. Commands now leave the caret after the new picture; Picture Format commands still act on it. The app still selects a picture the user inserts, as in Word. Fixes #965 Co-Authored-By: Claude Opus 5.5 (1M context) * Select a pasted picture again, keep pictures inserted in a row (#965) Desktop paste inserts through execute, so after the caret change it left a pasted picture unselected. WordApp::select_inserted_picture selects the picture just before the caret; run and the paste path call it, paste once after the last picture so a multi-file paste keeps them all. Tests for paste selection and for two inserts in a row. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- .../tests/picture_then_typing.rs | 31 ++++++++++++++ contributors/people.toml | 3 +- crates/engine/src/cmd/insert.rs | 6 ++- crates/engine/src/cmd/objects.rs | 41 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 36 ++++++++++++++++ crates/ui-egui/src/paste_picture.rs | 23 +++++++++++ docs/mcp.md | 3 ++ 8 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 apps/wordcraft-cli/tests/picture_then_typing.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1bd51adc1..2e48a8a90 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: a picture inserted by a command survives the next typing, #965; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; 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. @@ -268,6 +268,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) | | 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 | | 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) | +| 2026-10-14 | A picture or icon inserted by a command (`wordcraft-cli run`, MCP, control channel) leaves the caret after it, so the next `text.newParagraph` or `text.insert` no longer deletes it, and two inserted in a row are both kept; Picture Format commands still act on it, and a picture the user inserts or pastes in the app is still selected (#965) | | 2026-10-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable | | 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too | | 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) | @@ -449,6 +450,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) | | 2026-10-13 | trivial | Progress log: the Paragraph dialog leaves mixed formatting alone (#1346) | | 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) | +| 2026-10-14 | trivial | Progress log: a picture inserted by a command survives the next typing (#965) | | 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) | | 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) | | 2026-10-11 | trivial | Progress log: Reading Mode implementation | diff --git a/apps/wordcraft-cli/tests/picture_then_typing.rs b/apps/wordcraft-cli/tests/picture_then_typing.rs new file mode 100644 index 000000000..4857213e0 --- /dev/null +++ b/apps/wordcraft-cli/tests/picture_then_typing.rs @@ -0,0 +1,31 @@ +//! `wordcraft-cli run`: a picture inserted by `--cmd insert.picture` stays in the saved file when +//! the next commands type (#965). + +use std::process::Command; + +/// A 1x1 PNG. +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +#[test] +fn typing_after_insert_picture_keeps_it_in_the_saved_file() { + let dir = std::env::temp_dir().join(format!("wordcraft-cli-965-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (pic, out) = (dir.join("pic.png"), dir.join("p.docx")); + std::fs::write(&pic, PNG).unwrap(); + let picture = format!(r#"insert.picture={{"path":{},"width":250}}"#, serde_json::json!(pic.to_string_lossy())); + let cmds = [r#"text.insert={"text":"x"}"#, "text.newParagraph", &picture, "text.newParagraph", r#"text.insert={"text":"cap"}"#]; + let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")); + cli.args(["run", "--template", "blank"]); + for c in cmds { + cli.args(["--cmd", c]); + } + let run = cli.arg("--save").arg(&out).output().unwrap(); + let docx = std::fs::read(&out); + let _ = std::fs::remove_dir_all(&dir); + assert!(run.status.success(), "stderr: {}", String::from_utf8_lossy(&run.stderr)); + assert!(docx.unwrap().windows(11).any(|w| w == b"word/media/"), "the saved file keeps the picture"); +} diff --git a/contributors/people.toml b/contributors/people.toml index b1fe21397..de2aa3f85 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -84,10 +84,11 @@ display_name = "cyril265" [people.webdevtodayjason] display_name = "Jason Brashear" sync_github_name = false +[people.Nael-Nathanael] +sync_github_name = true [people.Losminek] -sync_github_name = true [people.benandez] real_name = "Martin Ball" display_name = "Martin Ball" diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index b95327646..f90f56291 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -223,7 +223,9 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult { } /// Insert an encoded image at the selection as an inline picture, `width` points wide (default: -/// its natural size at 96 ppi), never wider than the text column. +/// its natural size at 96 ppi), never wider than the text column. The caret ends after it, so a +/// script's next typing doesn't replace it (#965); Picture Format commands still act on it. The +/// app selects a picture the person inserts (`WordApp::run`). fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) -> CmdResult { if bytes.len() > 200 << 20 { return Err(CmdError::Failed("image is larger than 200 MB".into())); @@ -265,7 +267,7 @@ fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) let props = s.typing_props(); let at = delete_selection(s)?; let end = s.doc.insert_object(&at, obj, &props)?; - s.sel = Selection { anchor: at, focus: end }; + s.sel = Selection::caret(end); Ok(json!({"media": key, "width": w, "height": h})) } diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 163e4f75a..c0552d18d 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -1553,6 +1553,47 @@ mod tests { click_resize_and_drag("insert.picture", json!({"data": data, "width": 100})); } + /// #965: a picture or icon inserted by a command leaves the caret after it, so a script's next + /// Enter or typing keeps it (and the saved file has it); Picture Format commands still reach it. + #[test] + fn typing_after_an_inserted_picture_keeps_it() { + let data = super::super::insert::base64_encode(&png(20, 10)); + for (id, params) in [("insert.picture", json!({"data": data, "width": 100})), ("insert.icon", json!({"id": "sun"}))] { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "x"})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run(id, ¶ms).unwrap(); + assert!(s.sel.is_collapsed(), "{id}: caret, not a selection"); + assert!(selected(&s).is_some(), "{id}: still the object Picture Format acts on"); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "cap"})).unwrap(); + assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1), "{id}"); + let paras: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => Some(p.text.replace(wordcraft_doc::para::OBJ, "#")), + _ => None, + }) + .collect(); + assert_eq!(paras, ["X", "#", "cap"], "{id}"); + let docx = crate::io::save_bytes("p.docx", &s.doc).unwrap(); + assert!(docx.windows(11).any(|w| w == b"word/media/"), "{id}: the saved file keeps the picture"); + } + } + + /// #965: two pictures inserted by commands in a row are both kept; the second doesn't + /// replace the first. + #[test] + fn two_inserted_pictures_in_a_row_are_both_kept() { + let data = super::super::insert::base64_encode(&png(20, 10)); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.picture", &json!({"data": data})).unwrap(); + s.run("insert.picture", &json!({"data": data})).unwrap(); + assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(2)); + } + #[test] fn picture_pipeline() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a4b9da8c6..743f78d1f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -712,6 +712,21 @@ impl WordApp { self.session.view.draw.pens = std::mem::take(&mut self.ui.ink_pens); } + /// Select the picture or icon the person just inserted or pasted, so it shows its handles. + /// `insert.picture` leaves the caret after it, which scripts and agents keep so their next + /// typing doesn't replace it (#965). + pub(crate) fn select_inserted_picture(&mut self, r: &Result) { + if !r.as_ref().is_ok_and(|v| v.get("media").is_some() && v.get("canvasMember").is_none()) || !self.session.sel.is_collapsed() { + return; + } + let end = self.session.sel.focus.clone(); + let Some(off) = end.off.checked_sub(wordcraft_doc::para::OBJ.len_utf8()) else { return }; + let start = wordcraft_doc::Pos { off, ..end.clone() }; + if self.session.doc.para_at(&start).and_then(|p| p.object_at(off)).is_some_and(|o| o.is_drawing()) { + self.session.sel = wordcraft_engine::Selection { anchor: start, focus: end }; + } + } + /// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open, /// 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`). @@ -754,6 +769,9 @@ impl WordApp { return r; } let r = self.execute_user(id, params); + if matches!(id, "insert.picture" | "insert.icon") { + self.select_inserted_picture(&r); + } let r = system_clipboard::explain(self, id, r); // Check Accessibility shows its findings in the Accessibility pane, kept up to date there. if id == "file.accessibility" && r.is_ok() { @@ -5829,6 +5847,24 @@ mod tests { a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0) } + /// #965: a picture or icon the person inserts is selected, with handles, as in Word; one a + /// script inserts (control channel, MCP bridge) leaves the caret after it, and typing keeps it. + #[test] + fn inserted_picture_is_selected_for_the_user_not_for_scripts() { + let data = wordcraft_engine::cmd::insert::base64_encode(&png_bytes([200, 30, 30, 255])); + for (id, params) in [("insert.picture", json!({"data": data})), ("insert.icon", json!({"id": "sun"}))] { + let mut a = app(); + a.run(id, params.clone()).unwrap(); + assert!(crate::objects::selected(&a).is_some(), "{id}: the person's picture is selected"); + let mut a = app(); + a.execute(id, params).unwrap(); + assert!(a.session.sel.is_collapsed() && crate::objects::selected(&a).is_none(), "{id}: a script gets the caret after it"); + a.execute("text.newParagraph", json!({})).unwrap(); + a.execute("text.insert", json!({"text": "cap"})).unwrap(); + assert_eq!(object_count(&mut a), 1, "{id}"); + } + } + /// A picture pasted in the browser lands only when the page is the edit target (#1349); /// dropped or opened files always land. #[test] diff --git a/crates/ui-egui/src/paste_picture.rs b/crates/ui-egui/src/paste_picture.rs index 30e44d405..ef223e1bb 100644 --- a/crates/ui-egui/src/paste_picture.rs +++ b/crates/ui-egui/src/paste_picture.rs @@ -121,6 +121,8 @@ pub(crate) fn paste(app: &mut WordApp) -> Option> { r } }; + // Selected once, after the last picture: a selected picture is replaced by the next insert. + app.select_inserted_picture(&r); Some(r) } @@ -239,4 +241,25 @@ mod tests { let focus = a.session.sel.focus.clone(); assert_eq!(a.session.doc.para_at(&focus).map(|p| p.text.contains("wc-ribbon-paste")), Some(false)); } + + /// A pasted picture is selected, with handles, as one inserted from Insert › Pictures is + /// (#965); pasting several copied picture files keeps them all and selects the last. + #[test] + fn pasted_pictures_are_selected() { + let mut a = app_with(|| Some(ClipboardPicture::Pixels { width: 4, height: 4, rgba: vec![200; 4 * 4 * 4] })); + a.run("edit.paste", json!({})).unwrap(); + assert!(!a.session.sel.is_collapsed() && crate::objects::selected(&a).is_some(), "pixels: the pasted picture is selected"); + let dir = std::env::temp_dir().join(format!("wc-paste-965-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let files: Vec = (0..2).map(|i| dir.join(format!("{i}.png"))).collect(); + for f in &files { + std::fs::write(f, png_from_rgba(4, 4, vec![200; 4 * 4 * 4]).unwrap()).unwrap(); + } + let mut a = app_with(move || Some(ClipboardPicture::Files(files.clone()))); + let r = a.run("edit.paste", json!({})); + let _ = std::fs::remove_dir_all(&dir); + r.unwrap(); + assert_eq!(pictures(&mut a), 2, "files: both pictures are kept"); + assert!(!a.session.sel.is_collapsed() && crate::objects::selected(&a).is_some(), "files: the last picture is selected"); + } } diff --git a/docs/mcp.md b/docs/mcp.md index 56161eb11..50f8e07b5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -94,6 +94,9 @@ files it was given rather than guarding against other software on the computer. | `parity` | feature coverage | | `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) | +`insert.picture` and `insert.icon` leave the caret after the new picture, so typing next doesn't +replace it; Picture Format commands (`picture.*`, `arrange.*`) still act on it. + Resources: `wordcraft://document` (inspect) and `wordcraft://commands`. The acceptance test `crates/mcp/src/tests.rs` writes a formatted document using MCP only.