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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Nathanael
2026-10-14 19:31:55 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 9c57b950b9
commit 81b5602bf0
8 changed files with 143 additions and 4 deletions
+3 -1
View File
@@ -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 |
@@ -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");
}
+2 -1
View File
@@ -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"
+4 -2
View File
@@ -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<u8>, width: Option<f32>, 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<u8>, width: Option<f32>, 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}))
}
+41
View File
@@ -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, &params).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<String> = 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());
+36
View File
@@ -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<Value, String>) {
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]
+23
View File
@@ -121,6 +121,8 @@ pub(crate) fn paste(app: &mut WordApp) -> Option<Result<Value, String>> {
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<PathBuf> = (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");
}
}
+3
View File
@@ -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.