diff --git a/ROADMAP.md b/ROADMAP.md index 69d1d3481..a73ea530d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637). - 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools). Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. +- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs. - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). Four geometry-dependent layout controls request Source Sans 3 explicitly. Installed substitute fonts no longer choose their line breaks. Assertions and renderer font fallback policy are unchanged. diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 77af7edc4..dca3cffb0 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -864,6 +864,25 @@ mod editing_tests { assert_eq!(typed, None, "a placeholder style localized in id and name"); } + /// Typed text with line breaks in it (`type_text` with paragraphs, a typed paste) is the + /// Enter key pressed between the lines, so a one-line control, where Enter does nothing, stays + /// one paragraph, and a multi-line one splits (#3973: the line breaks went in as one command + /// and split a one-line control). + #[test] + fn typed_line_breaks_do_not_split_a_one_line_control() { + for (multi_line, paragraphs) in [(false, 1), (true, 2)] { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "Name: "})); + run(&mut s, "developer.plainText", json!({"title": "Name", "tag": "name"})); + run(&mut s, "developer.properties", json!({"control": "name", "multiLine": multi_line})); + run(&mut s, "caret.right", json!({})); + run(&mut s, "text.insert", json!({"text": "Ada\rLovelace", "raw": true})); + assert_eq!(s.doc.body.len(), paragraphs, "multi-line control: {multi_line}"); + let r = s.doc.control_at(&s.sel.focus).unwrap(); + assert_eq!(r.control.tag, "name", "still inside the control"); + } + } + #[test] fn typing_replaces_the_placeholder_and_emptying_brings_it_back() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2e71472a7..763fad036 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -311,8 +311,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> } let mut at = s.sel.focus.clone(); let mut first = true; + // Typed line breaks are the Enter key between the lines: in a one-line control it does + // nothing, so the lines run together and the control stays one paragraph (pasted text keeps + // its breaks, as before). + let no_breaks = enter && controls::refuses_paragraph(s); for part in text.split(['\r', '\u{2029}']) { - if !first { + if !first && !no_breaks { at = split_para_as(s, &at, enter)?; } first = false; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 781f24d02..12ea973f1 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -59,6 +59,77 @@ fn agent_writes_a_formatted_document() { assert!(p["percent"].as_f64().unwrap() > 50.0); } +/// The document's paragraphs as (text, in a list) from `inspect_document`. +fn paragraphs(s: &mut Server) -> Vec<(String, bool)> { + let doc = tool(s, "inspect_document", json!({})); + doc["blocks"].as_array().unwrap().iter().map(|b| (b["text"].as_str().unwrap_or("").to_string(), !b["list"].is_null())).collect() +} + +/// `type_text` with `paragraphs` is one command: it used to be a `text.newParagraph` and a +/// `text.insert` per line, each snapshotting the whole document for Undo (quadratic: 10,000 lines +/// took 2.8 s; one command takes 7 ms) and each its own Undo step (#3973). The document it makes +/// is the one the per-line way made, a CRLF is one break, and one Undo takes all of it back. +#[test] +fn type_text_paragraphs_is_one_step_and_makes_the_same_paragraphs() { + let per_line = |s: &mut Server, text: &str| { + for (i, line) in text.split('\n').enumerate() { + if i > 0 { + tool(s, "execute", json!({"command": "text.newParagraph"})); + } + if !line.is_empty() { + tool(s, "type_text", json!({"text": line})); + } + } + }; + let setup = |s: &mut Server, listed: bool| { + tool(s, "new_document", json!({})); + tool(s, "type_text", json!({"text": "Start"})); + if listed { + tool(s, "execute", json!({"command": "para.bullets"})); + } + }; + for listed in [false, true] { + // Text AutoCorrect has nothing to change, so only the paragraphs are compared. + for text in ["One\nTwo\nThree", "One\n\n\nTwo", "\nLead", "Trail\n", "A b c\n Indented\n\tTabbed", "X"] { + let mut old = Server::new(Box::new(Headless::default())); + setup(&mut old, listed); + per_line(&mut old, text); + let mut new = Server::new(Box::new(Headless::default())); + setup(&mut new, listed); + tool(&mut new, "type_text", json!({"text": text, "paragraphs": true})); + assert_eq!(paragraphs(&mut new), paragraphs(&mut old), "{text:?} (in a list: {listed})"); + } + } + + // The lines are typed as they are: Enter's AutoCorrect (a capital for the next sentence, a + // fixed typo) doesn't run on the breaks. The per-line way capitalised "two" (and not the last + // line, which no Enter followed), so what was written depended on the line's place. + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "one\ntwo teh\nthree", "paragraphs": true})); + let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); + assert_eq!(texts, ["one", "two teh", "three"]); + + // CRLF is one break per line end. + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "a\r\nb\r\nc", "paragraphs": true})); + let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); + assert_eq!(texts, ["a", "b", "c"]); + + // One Undo step takes all the lines back. + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "keep"})); + // Moving the caret ends the typing that "keep" was part of, so it isn't undone with the rest. + tool(&mut s, "execute", json!({"command": "caret.docEnd"})); + tool(&mut s, "type_text", json!({"text": "\none\ntwo\nthree\nfour", "paragraphs": true})); + assert_eq!(paragraphs(&mut s).len(), 5); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); + assert_eq!(texts, ["keep"], "one Undo removes every line"); +} + #[test] fn resources() { let mut s = Server::new(Box::new(Headless::default())); @@ -120,17 +191,17 @@ fn headless_sessions_keep_the_dictionary_in_memory() { assert!(h.session.user_dict_path.is_none()); } -/// A backend whose paragraph break is refused while text insertion would succeed. -struct RefusesParagraph { - calls: std::sync::Arc>>, +/// A backend that refuses every text insertion (a protected document, say). +struct RefusesInsert { + calls: std::sync::Arc>>, } -impl crate::backend::Backend for RefusesParagraph { +impl crate::backend::Backend for RefusesInsert { fn call(&mut self, _method: &str, params: Value) -> Result { let command = params["command"].as_str().unwrap_or("").to_string(); - self.calls.lock().unwrap().push(command.clone()); + self.calls.lock().unwrap().push((command.clone(), params["params"].clone())); match command.as_str() { - "text.newParagraph" => Err("paragraph break refused".into()), + "text.insert" => Err("editing refused".into()), _ => Ok(json!({"ok": true})), } } @@ -138,17 +209,22 @@ impl crate::backend::Backend for RefusesParagraph { false } fn describe(&self) -> String { - "refuses paragraphs".into() + "refuses edits".into() } } -/// #1356: a refused paragraph break is reported and the next line is not typed after it. +/// #1356: a refused edit is reported and nothing is typed after it. The lines are one +/// `text.insert` now (#3973), so a protected document refuses all of them at once, never a +/// line at a time with the next one typed after the break that failed. #[test] -fn type_text_stops_at_a_refused_paragraph_break() { +fn type_text_paragraphs_reports_a_refusal_and_types_nothing_after_it() { let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() })); + let mut s = Server::new(Box::new(RefusesInsert { calls: calls.clone() })); let r = call(&mut s, 11, "tools/call", json!({"name": "type_text", "arguments": {"text": "one\ntwo", "paragraphs": true}})); assert_eq!(r["result"]["isError"], true, "{r}"); - assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}"); - assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("editing refused"), "{r}"); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "one command for all the lines: {calls:?}"); + assert_eq!(calls[0].0, "text.insert"); + assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break"); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 47e162a0b..f543043e4 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -102,7 +102,7 @@ pub fn tool_definitions() -> Value { tool( "type_text", "Type text", - "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.", + "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (all in one step, one Undo; the line breaks are plain text breaks: no AutoCorrect or AutoFormat runs on them).", obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), false ), @@ -229,22 +229,11 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { "type_text" => { let t = st("text").unwrap_or(""); if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) { - let mut last = Ok(Value::Null); - for (i, line) in t.split('\n').enumerate() { - if i > 0 { - last = exec(b, "text.newParagraph", json!({})); - if last.is_err() { - break; - } - } - if !line.is_empty() { - last = exec(b, "text.insert", json!({"text": line, "raw": true})); - } - if last.is_err() { - break; - } - } - wrap(last) + // One command for all the lines: `text.insert` takes a carriage return as a + // paragraph break. A command per line was quadratic in the document (each one + // snapshots all of it for Undo: 10,000 lines took 2.8 s, 7 ms in one command), + // and left an Undo step per line. CRLF is one break, not two (#3973). + wrap(exec(b, "text.insert", json!({"text": t.replace("\r\n", "\n").replace('\n', "\r"), "raw": true}))) } else { wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) } diff --git a/docs/mcp.md b/docs/mcp.md index 5ed66634d..c45228763 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -108,7 +108,7 @@ files it was given rather than guarding against other software on the computer. | `batch` | run several commands in order | | `new_document` | blank, sample, letter, resume, report | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | -| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) | +| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs, in one command and one Undo step: 10,000 lines take ~10 ms; the line breaks are plain breaks, so AutoCorrect doesn't run on them) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | | `render_page` | a page as PNG |