From d5f23907dc430873f16393e2f519d955e0fcf2c1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:15:42 +0100 Subject: [PATCH 1/6] Bound total LCS work per review.compare (#4006) Fixes #3632 Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/tools.rs | 63 +++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index e42035bd0..1f1aaa658 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -1361,7 +1361,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult { // Tables match as a whole, by their text. Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))), }; - let ops = lcs_ops(&old.iter().map(key).collect::>(), &new.iter().map(key).collect::>()); + let mut budget = MAX_COMPARE_CELLS; + let ops = lcs_ops(&old.iter().map(key).collect::>(), &new.iter().map(key).collect::>(), &mut budget); let mut out: Vec> = Vec::new(); let (mut i, mut j) = (0usize, 0usize); let mut changes = 0usize; @@ -1397,7 +1398,7 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult { for x in 0..dels.max(inss) { let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None }); match (a.map(|x| &**x), b.map(|x| &**x)) { - (Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts))), + (Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts, &mut budget))), _ => { if let Some(x) = a { out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0))); @@ -1473,15 +1474,26 @@ enum Op { /// compared as deleted and inserted, never dropped. const MAX_LCS: usize = 4000; +/// Comparison table cells one compare may fill over all its tables (blocks and every changed +/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted. +const MAX_COMPARE_CELLS: u64 = 50_000_000; + /// LCS edit script between two sequences: the common start and end are matched first, then the -/// middle (bounded size). -fn lcs_ops(a: &[T], b: &[T]) -> Vec { +/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the +/// middle is deleted and inserted whole. +fn lcs_ops(a: &[T], b: &[T], budget: &mut u64) -> Vec { let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count(); let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default()); let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count(); let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default()); let mut ops = vec![Op::Same; pre]; - let (n, m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS)); + let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS)); + let cells = (n as u64).saturating_mul(m as u64); + if cells > *budget { + (n, m) = (0, 0); + } else { + *budget -= cells; + } let mut t = vec![vec![0u32; m + 1]; n + 1]; for i in (0..n).rev() { for j in (0..m).rev() { @@ -1595,11 +1607,11 @@ fn tokens(s: &str, characters: bool) -> Vec { v } -fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions) -> Paragraph { +fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph { let (at, bt) = (a.plain_text(), b.plain_text()); let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF; let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars)); - let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::>(), &tb.iter().map(|t| opts.key(t)).collect::>()); + let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::>(), &tb.iter().map(|t| opts.key(t)).collect::>(), budget); // Only at character granularity: a matched whole word almost never happens by coincidence, // but a matched single character very often does (digits, punctuation), so only there does // a short interior match need folding back into its surrounding edit. @@ -2248,12 +2260,12 @@ mod tests { fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() { // "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches // ('1', '.', '0') threaded through what a reader expects to be one clean replacement. - let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0']); + let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0'], &mut { u64::MAX }); assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing"); assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]); // A plain single-character replacement flanked by a real common prefix and suffix has no // interior match to begin with (the DP's middle is pure delete/insert), so it's untouched. - let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f']); + let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f'], &mut { u64::MAX }); assert_eq!(collapse_noise_matches(&ops), ops); } @@ -2271,6 +2283,39 @@ mod tests { ); } + #[test] + fn compare_bounds_total_diff_work() { + // Many long, slightly different paragraphs: each pair alone fits one comparison table, + // but the work over all pairs must stay bounded. Pairs past the budget are compared + // whole (deleted and inserted), and every pair still counts as a change. + let words = 3000; + let paras = 12; + let doc = |rev: bool| -> String { + (0..paras) + .map(|p| (0..words).map(|w| if w % 10 == 0 && rev { format!("x{p}w{w}") } else { format!("p{p}w{w}") }).collect::>().join(" ")) + .collect::>() + .join("\n") + }; + let (original, revised) = (doc(false), doc(true)); + let mut s = Session::new(Document::from_text(&original)); + let r = s.run("review.compare", &json!({"text": revised})).unwrap(); + assert_eq!(r["changes"], paras); + // A pair diffed word by word keeps most of its text unmarked; one replaced whole keeps + // only its common start and end. + let word_diffed = |b: &Arc| { + let p = b.as_para().unwrap(); + let same: usize = p.run_ranges().filter(|(_, c)| c.ins.is_none() && c.del.is_none()).map(|(r, _)| r.len()).sum(); + same * 2 > p.text.len() + }; + assert!(word_diffed(&s.doc.body[0])); + let diffed = s.doc.body.iter().filter(|b| word_diffed(b)).count() as u64; + let cells = (words as u64) * (words as u64); + assert!(diffed * cells <= 50_000_000 + cells, "{diffed} pairs diffed word by word"); + assert!(!word_diffed(s.doc.body.last().unwrap())); + s.run("review.acceptAll", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), revised); + } + #[test] fn macros_record_and_run() { let mut s = Session::new(Document::new()); From bf70f48bbd7d6b9b6c6ad8ca33d7b096fa00d714 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:17:58 +0100 Subject: [PATCH 2/6] Bound MCP stdio request and bridge reply line reads (#3950) * Bound MCP stdio request and bridge reply line reads Fixes #3685 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * #3685: an unreadable reply from the app is final, not retried A reply that is too long (or not UTF-8) means the app already ran the request; retrying on a new connection could run a state-changing request twice. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/mcp/src/backend.rs | 52 +++++++++++++++++++++++++++++++---- crates/mcp/src/server.rs | 57 +++++++++++++++++++++++++++++++++++---- crates/mcp/src/tests.rs | 17 ++++++++++++ 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 78cec12b2..a07c89391 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,12 +1,17 @@ //! Where MCP tool calls end up: a control-channel method call. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufReader, Write}; use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs}; use std::path::Path; use std::time::Duration; use serde_json::{Value, json}; +use crate::server::{BoundedLine, read_bounded_line}; + +/// Longest reply line accepted from the app (screenshots and `document.inspect` are large). +const MAX_REPLY_LINE: usize = 256 << 20; + /// Something that answers control-channel methods (`engine.execute`, `document.inspect`, /// `ui.pointer`, `ui.render`, …). See `wordcraft_ui_egui::control` for the full list. pub trait Backend { @@ -161,11 +166,11 @@ impl Remote { writer.write_all(line.as_bytes())?; writer.write_all(b"\n")?; writer.flush()?; - let mut reply = String::new(); - if reader.read_line(&mut reply)? == 0 { - return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); + match read_bounded_line(reader, MAX_REPLY_LINE)? { + BoundedLine::Line(reply) => Ok(reply), + BoundedLine::Eof => Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")), + BoundedLine::TooLong => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long")), } - Ok(reply) } /// One request and its reply, with one retry on a new connection (the app may have @@ -189,6 +194,11 @@ impl Remote { Ok(r) => break r, Err(e) => { self.conn = None; + // A reply that arrived but can't be read (too long, not UTF-8): the app has + // run the request, so sending it again could run a state-changing one twice. + if e.kind() == std::io::ErrorKind::InvalidData { + return Err(format!("bad reply from app: {e}")); + } if !first { return Err(unreachable(&self.addr, e, self.connected)); } @@ -246,6 +256,7 @@ impl Backend for Remote { #[cfg(test)] mod tests { use super::*; + use std::io::BufRead; use std::net::{SocketAddr, TcpListener}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -335,6 +346,37 @@ mod tests { (port, rx) } + #[test] + fn an_unreadable_reply_is_not_retried() { + // The app answered something that can't be read (here not UTF-8; an over-long reply + // takes the same path): the request ran, so it must not be sent again. + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(AtomicUsize::new(0)); + let n = seen.clone(); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let n = n.clone(); + std::thread::spawn(move || { + let mut out = stream.try_clone().unwrap(); + for line in BufReader::new(stream).lines() { + if line.is_err() { + return; + } + n.fetch_add(1, Ordering::SeqCst); + if out.write_all(b"\xff\xfe\n").is_err() { + return; + } + } + }); + } + }); + let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k1")).unwrap(); + let e = r.call("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}})).unwrap_err(); + assert!(e.contains("bad reply"), "{e}"); + assert_eq!(seen.load(Ordering::SeqCst), 1, "the request was sent once"); + } + fn fixed_key(key: &'static str) -> KeyFinder { Box::new(move |_| Ok(key.to_string())) } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 63f6764c6..0bca29e64 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -1,6 +1,6 @@ //! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods. -use std::io::{BufRead, Write}; +use std::io::{BufRead, Read, Write}; use serde_json::{Value, json}; @@ -28,6 +28,49 @@ execute format.bold → inspect_document to verify → render_page to look at th pub const DOC_URI: &str = "wordcraft://document"; pub const COMMANDS_URI: &str = "wordcraft://commands"; +/// Longest request line accepted on stdin. +pub const MAX_REQUEST_LINE: usize = 1 << 20; + +/// One bounded line read. +pub(crate) enum BoundedLine { + Eof, + Line(String), + /// The line exceeded the limit; the rest of it has been discarded. + TooLong, +} + +/// Read one line of at most `max` bytes (excluding the newline) without buffering more than +/// `max + 1` bytes; an over-long line is skipped up to its newline. +pub(crate) fn read_bounded_line(reader: &mut impl BufRead, max: usize) -> std::io::Result { + let mut buf = Vec::new(); + reader.by_ref().take(max as u64 + 1).read_until(b'\n', &mut buf)?; + if buf.is_empty() { + return Ok(BoundedLine::Eof); + } + if buf.last() != Some(&b'\n') && buf.len() > max { + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + break; + } + let found = chunk.iter().position(|&b| b == b'\n'); + let used = found.map_or(chunk.len(), |i| i + 1); + reader.consume(used); + if found.is_some() { + break; + } + } + return Ok(BoundedLine::TooLong); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + } + String::from_utf8(buf).map(BoundedLine::Line).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) +} + /// An MCP server bound to one backend. pub struct Server { backend: Box, @@ -58,10 +101,14 @@ impl Server { /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is /// the protocol stream). - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - for line in input.lines() { - let line = line?; - if let Some(reply) = self.handle_line(&line) { + pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + loop { + let reply = match read_bounded_line(&mut input, MAX_REQUEST_LINE)? { + BoundedLine::Eof => break, + BoundedLine::Line(line) => self.handle_line(&line), + BoundedLine::TooLong => Some(error(Value::Null, INVALID_REQUEST, "request line too long").to_string()), + }; + if let Some(reply) = reply { output.write_all(reply.as_bytes())?; output.write_all(b"\n")?; output.flush()?; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 12ea973f1..f23f72f75 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -228,3 +228,20 @@ fn type_text_paragraphs_reports_a_refusal_and_types_nothing_after_it() { assert_eq!(calls[0].0, "text.insert"); assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break"); } + +/// #3685: an over-long request line gets an error reply and the server keeps serving. +#[test] +fn over_long_request_line_is_rejected_and_serving_continues() { + let mut s = Server::new(Box::new(Headless::default())); + let mut input = vec![b'a'; (1 << 20) + 10]; + input.push(b'\n'); + input.extend_from_slice(br#"{"jsonrpc":"2.0","id":5,"method":"ping"}"#); + input.push(b'\n'); + let mut out = Vec::new(); + s.serve(std::io::Cursor::new(input), &mut out).unwrap(); + let text = String::from_utf8(out).unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines[0].contains("too long"), "{}", lines[0]); + assert!(lines[1].contains(r#""id":5"#), "{}", lines[1]); +} From 14891616113093c2c5874ae5d2a93712d178c766 Mon Sep 17 00:00:00 2001 From: "William de O. Silva" <157059679+will1788@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:21:53 -0300 Subject: [PATCH 3/6] fix/canvas-picture-crop-activation (#2440) Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bb610baaf..ff98ce055 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3502,6 +3502,7 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) { item(ui, app, "Change Picture…", "ui.changePicture", json!({})); item(ui, app, "Reset Picture", "picture.reset", json!({})); item(ui, app, "Size and Crop…", "ui.tab", json!({"tab": "Picture Format"})); + item(ui, app, "Crop", "ui.crop", json!({})); } if picture || object { // A numbered caption under (or over) the picture, shape, text box or chart (#1230). @@ -7176,6 +7177,63 @@ mod tests { assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("にほん")); } + #[test] + fn context_menu_crop_item_activates_crop_mode() { + use egui_kittest::kittest::Queryable; + use wordcraft_doc::para::{InlineObject, OBJ}; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + let pic = + InlineObject::Image { media: "m".into(), w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }; + app.session.doc.insert_object(&Pos::body(0, 0), pic, &Default::default()).unwrap(); + let end = Pos::body(0, OBJ.len_utf8()); + app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": end})).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); + }, + app, + ); + for _ in 0..6 { + h.step(); + } + let (page, rect) = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).map(|o| (o.page, o.rect)).expect("laid out"); + let at = page_to_screen(h.state_mut(), page, rect.x + rect.w / 2.0, rect.y + rect.h / 2.0).expect("on screen"); + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + h.step(); + assert!(h.state().canvas.context_menu_open, "context menu is open on image"); + h.get_by_label("Crop ").click(); + for _ in 0..3 { + h.step(); + } + assert!(crate::crop::active(h.state()).is_some(), "crop mode is active after clicking Crop in context menu"); + } + + #[test] + fn crop_is_disabled_when_cursor_is_adjacent_without_selection() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + let pic = wordcraft_doc::para::InlineObject::Image { + media: "m".into(), + w: 200.0, + h: 100.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; + app.session.doc.insert_object(&Pos::body(0, 0), pic, &Default::default()).unwrap(); + // Caret is collapsed right at pos 0 (adjacent to the picture). + app.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0)); + assert!(crate::ribbon::has_picture_selected(&app.session), "adjacent caret sees picture in ribbon"); + assert!(!crate::widgets::enabled(&app, "ui.crop"), "ui.crop is disabled when not selected as object"); + assert!(crate::crop::active(&app).is_none()); + } + fn page_at_index(rects: &[Rect], p: Pos2) -> Option { nearest_page(rects, p) } From 40a7fe6be26045b1f6edf0f501bc33157320989a Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:34:23 +0100 Subject: [PATCH 4/6] Make three-column header/footer presets editable tables (#2197) (#2266) Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 + crates/engine/src/cmd/insert.rs | 32 +++++- crates/engine/tests/it/main.rs | 1 + .../engine/tests/it/three_column_headers.rs | 101 ++++++++++++++++++ crates/ui-egui/src/canvas.rs | 31 ++++-- docs/file-format-parity.md | 3 + docs/gaps.md | 3 + docs/typing-parity.md | 3 + docs/ui-parity.md | 3 + 9 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 crates/engine/tests/it/three_column_headers.rs diff --git a/ROADMAP.md b/ROADMAP.md index 91e5ce842..7ff517130 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,6 +99,8 @@ the two partial ones (real-world .docx, printing) complete, roughly. Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parity.md#feature-areas). +> **Three-column preset update:** 2026-10-15 · headers and footers use three editable, borderless table cells with guides while editing (#2197). Readiness estimates unchanged. + ## Languages | Language | Code | Status | UI strings | @@ -330,6 +332,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-15 | Layout › Columns with text selected puts only that text in columns, as in Word (#2024): a continuous section break goes where the selection starts and one where it ends (splitting a paragraph there; none at the document's start or end, or where a section already begins or ends), and the columns go on the section between, one undo step; More Columns… offers "Selected text" and picks it. Before, the whole section took the columns | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | +| 2026-10-15 | The Three columns header/footer preset inserts a borderless one-row, three-cell table spanning the section text width, with left/center/right alignment (#2197). Tab moves between the cells. Cell guides show while editing the story and disappear when it is closed, unless View Gridlines is enabled. | | 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | | 2026-10-18 | Different First Page works from page 1 (#2826): Insert › Header and Edit Header with the caret on page 1, and a double-click there while another header is being edited, go into page 1's own header instead of the default one page 1 no longer shows. | @@ -783,6 +786,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-17 | trivial | Progress log: Scale Font Sizes command (#1475) | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | +| 2026-10-15 | trivial | Three-column header/footer presets use editable table cells and screen-only editing guides (#2197); readiness unchanged | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) | | 2026-10-16 | trivial | Progress log: opening a DOCX no longer copies its pictures | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 42a242ef7..a556f48dc 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap}; -use wordcraft_doc::props::{Align, CharProps, Rgb, TabAlign, TabStop, TextColor}; +use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor}; use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block}; use super::{delete_selection, sel_result, split_para, type_text}; @@ -586,18 +586,42 @@ fn hf_part(s: &mut Session, header: bool) -> u32 { id } +/// Three independently editable cells spanning the section's text width. +fn three_column_hf(s: &Session, style: &str) -> wordcraft_doc::Blocks { + let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; + // The section `section_mut` would pick, read without making anything unique. + let sections = s.doc.sections(); + let own = sections.iter().position(|(end, _)| block <= *end).filter(|i| i + 1 < sections.len()).and_then(|i| sections.get(i)); + let width = own.map_or_else(|| s.doc.last_section.text_width(), |(_, sec)| sec.text_width()); + let mut table = Table::new(1, 3, width); + table.props.style = Some("TableNormal".into()); + table.props.width = Some(width); + table.props.fixed = true; + // Explicit nil borders also override borders inherited from the document's table style. + table.props.borders = Some(Borders::all(Border::default())); + for row in &mut table.rows { + for (cell, align) in row.cells.iter_mut().zip([Align::Left, Align::Center, Align::Right]) { + let mut para = Paragraph::new().styled(style); + para.props.align = Some(align); + cell.blocks = vec![para_block(para)]; + } + } + // Keep a trailing paragraph so the story can also be edited after the table. + vec![std::sync::Arc::new(Block::Table(table)), para_block(Paragraph::new().styled(style))] +} + fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult { let id = hf_part(s, header); let style = if header { "Header" } else { "Footer" }; if let Some(text) = p::str(v, "text") { let blocks = text.split('\n').map(|l| para_block(Paragraph::with_text(l, CharProps::default()).styled(style))).collect(); s.doc.set_story(StoryRef::Part(id), blocks)?; + } else if p::str(v, "preset") == Some("blankThree") { + let blocks = three_column_hf(s, style); + s.doc.set_story(StoryRef::Part(id), blocks)?; } else if let Some(preset) = p::str(v, "preset") { let mut para = Paragraph::new().styled(style); match preset { - "blankThree" => { - para.insert_text(0, "[Type here]\t[Type here]\t[Type here]", &CharProps::default())?; - } "title" => { para.insert_text(0, if s.doc.core.title.is_empty() { "[Document title]" } else { &s.doc.core.title }, &CharProps::default())?; para.props.align = Some(Align::Center); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 860d23ece..5b00084fd 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -8,3 +8,4 @@ mod recovery_format; mod rtf_sections; #[cfg(not(target_arch = "wasm32"))] mod safe_save; +mod three_column_headers; diff --git a/crates/engine/tests/it/three_column_headers.rs b/crates/engine/tests/it/three_column_headers.rs new file mode 100644 index 000000000..6312a03a4 --- /dev/null +++ b/crates/engine/tests/it/three_column_headers.rs @@ -0,0 +1,101 @@ +//! #2197: three-column header/footer presets are editable, borderless tables. +use serde_json::json; +use wordcraft_doc::props::Align; +use wordcraft_doc::{Block, Document, Path, Pos, StoryRef, Table}; +use wordcraft_engine::Session; + +fn table(doc: &Document, story: StoryRef) -> &Table { + let Block::Table(table) = &*doc.story(story).unwrap()[0] else { panic!("three columns must be a table") }; + table +} + +#[test] +fn three_columns_are_borderless_cells_with_the_story_style_and_section_width() { + for (command, header, style, existing) in [ + ("insert.header", true, "Header", false), + ("insert.header", true, "Header", true), + ("insert.footer", false, "Footer", false), + ("insert.footer", false, "Footer", true), + ] { + let mut doc = Document::from_text("Body text"); + doc.last_section.page_w = 540.0; + doc.last_section.margin_left = 54.0; + doc.last_section.margin_right = 54.0; + let width = doc.last_section.text_width(); + let mut s = Session::new(doc); + if existing { + s.run(command, &json!({"text": "Original story content"})).unwrap(); + s.run("insert.closeHeader", &json!({})).unwrap(); + } + let before = s.doc.clone(); + let depth = s.undo_depth(); + let id = s.run(command, &json!({"preset": "blankThree"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + let t = table(&s.doc, story); + assert_eq!(t.rows.len(), 1); + assert_eq!(t.rows[0].cells.len(), 3); + assert_eq!(t.grid.len(), 3); + assert!((t.grid.iter().sum::() - width).abs() < 0.01); + assert!(t.grid.iter().all(|w| (*w - width / 3.0).abs() < 0.01)); + assert!(t.props.fixed); + assert!(!t.props.borders.as_ref().unwrap().any_visible()); + assert_ne!(t.props.style.as_deref(), Some("TableGrid")); + for (cell, align) in t.rows[0].cells.iter().zip([Align::Left, Align::Center, Align::Right]) { + let Block::Para(p) = &*cell.blocks[0] else { panic!("cell paragraph missing") }; + assert!(p.text.is_empty()); + assert_eq!(p.props.style.as_deref(), Some(style)); + assert_eq!(p.props.align, Some(align)); + } + assert_eq!(s.sel.focus, Pos { story, path: Path(vec![0, 0, 0, 0]), off: 0 }); + let after = s.doc.clone(); + assert_eq!(s.undo_depth(), depth + 1); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, before); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc, after); + for (cell, text) in ["Left text", "Middle text", "Right text"].into_iter().enumerate() { + if cell > 0 { + s.run("text.tab", &json!({})).unwrap(); + } + assert_eq!(s.sel.focus, Pos { story, path: Path(vec![0, 0, cell as u32, 0]), off: 0 }); + s.run("text.insert", &json!({"text": text})).unwrap(); + } + let filled = s.doc.clone(); + s.run("edit.undo", &json!({})).unwrap(); + assert!(!s.doc.plain_text(story).contains("Right text")); + assert!(s.doc.plain_text(story).contains("Middle text")); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc, filled); + s.run("select.text", &json!({"text": "Middle text"})).unwrap(); + s.run("format.bold", &json!({"value": true})).unwrap(); + let mut back = s.doc.clone(); + for _ in 0..2 { + back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap(); + let id = if header { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap(); + let t = table(&back, StoryRef::Part(id)); + assert_eq!(t.rows.len(), 1); + assert_eq!(t.rows[0].cells.len(), 3); + assert!((t.grid.iter().sum::() - width).abs() < 0.1); + assert!(t.props.fixed); + assert!(!t.props.borders.as_ref().unwrap().any_visible()); + for ((cell, text), align) in + t.rows[0].cells.iter().zip(["Left text", "Middle text", "Right text"]).zip([Align::Left, Align::Center, Align::Right]) + { + let Block::Para(p) = &*cell.blocks[0] else { panic!("cell lost") }; + assert_eq!(p.text, text); + assert_eq!(p.props.style.as_deref(), Some(style)); + assert_eq!(p.props.align, Some(align)); + assert_eq!(p.props_at(0).bold == Some(true), text == "Middle text"); + } + assert_eq!(back.plain_text(StoryRef::Body), "Body text"); + } + } +} + +#[test] +fn explicit_text_keeps_precedence_over_the_preset() { + let mut s = Session::new(Document::new()); + let id = s.run("insert.footer", &json!({"text": "Keep explicit text", "preset": "blankThree"})).unwrap()["story"].as_u64().unwrap() as u32; + assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Keep explicit text"); + assert!(matches!(&*s.doc.story(StoryRef::Part(id)).unwrap()[0], Block::Para(_))); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ff98ce055..3ba32ad37 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -921,11 +921,13 @@ fn drawing_grid(body: wordcraft_geom::Rect, step: (f32, f32)) -> (Vec, Vec< (axis(body.x, body.w, step.0), axis(body.y, body.h, step.1)) } -/// The cells Table Layout › View Gridlines outlines on a page: the body's tables and the -/// header's and footer's (#1628), skipping cells covered by a merge. -fn gridline_cells(page: &Page) -> impl Iterator + '_ { - page.items.iter().chain(&page.header).chain(&page.footer).filter_map(|it| match it { - Placed::Cell { rect, covered: false, .. } => Some(*rect), +/// Global View Gridlines outlines every table (#1628). While editing a header/footer, also +/// outline that story's cells (#2197), without adding printable borders or changing the view flag. +fn gridline_cells(page: &Page, all: bool, editing: Option) -> impl Iterator + '_ { + // Automatic header/footer guides need not scan a potentially large body placement list. + let body = if all { page.items.as_slice() } else { &[] }; + body.iter().chain(&page.header).chain(&page.footer).filter_map(move |it| match it { + Placed::Cell { rect, covered: false, story, .. } if all || Some(*story) == editing => Some(*rect), _ => None, }) } @@ -1144,6 +1146,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let caret = layout.caret_on(&preview_sel(app).unwrap_or(app.session.sel_ends()).focus, app.session.page_hint); // Editing a header/footer (or a note) dims the body; once per frame, for every page. let dim_body = dims_body(app, &layout); + let grid_story = text_box_host(app, &layout).unwrap_or(app.session.sel.focus.story); + let grid_story = matches!(part_kind(app, grid_story), Some(PartKind::Header | PartKind::Footer)).then_some(grid_story); if let Some(c) = caret && !app.session.selects_whole_story() { @@ -1474,9 +1478,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { painter.line_segment([pos2(x0, sy), pos2(x1, sy)], stroke); } } - // Table Layout › View Gridlines: table cell outlines (on screen only). - if app.session.view.table_gridlines { - for rect in gridline_cells(page) { + // The current header/footer's cells have editing guides; explicit View Gridlines + // still outlines every table. Neither kind of guide is part of the document. + if app.session.view.table_gridlines || grid_story.is_some() { + for rect in gridline_cells(page, app.session.view.table_gridlines, grid_story) { let r = Rect::from_min_size( pos2(visual_sr.min.x + rect.x * page_scale, visual_sr.min.y + rect.y * page_scale), vec2(rect.w * page_scale, rect.h * page_scale), @@ -6410,12 +6415,18 @@ mod tests { }; let page = Page { items: vec![cell(100.0, false, StoryRef::Body), cell(110.0, true, StoryRef::Body)], - header: vec![cell(10.0, false, StoryRef::Part(1))], + header: vec![cell(10.0, false, StoryRef::Part(1)), cell(20.0, true, StoryRef::Part(1))], footer: vec![cell(200.0, false, StoryRef::Part(2))], ..Default::default() }; - let ys: Vec = gridline_cells(&page).map(|r| r.y).collect(); + let ys: Vec = gridline_cells(&page, true, None).map(|r| r.y).collect(); assert_eq!(ys, [100.0, 10.0, 200.0]); + let ys: Vec = gridline_cells(&page, true, Some(StoryRef::Part(2))).map(|r| r.y).collect(); + assert_eq!(ys, [100.0, 10.0, 200.0], "explicit View Gridlines still shows the other stories"); + let ys = |editing| gridline_cells(&page, false, editing).map(|r| r.y).collect::>(); + assert_eq!(ys(Some(StoryRef::Part(1))), [10.0]); + assert_eq!(ys(Some(StoryRef::Part(2))), [200.0]); + assert!(ys(None).is_empty(), "closing header/footer editing removes the automatic guides"); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index caed9879c..b263e05f4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -182,6 +182,8 @@ from these synthetic package checks, and native Word rendering/binding behavior Native `io::save_path_with` serializes (and, for password-protected DOCX, encrypts) before creating an exclusively owned sibling temporary file. It writes and synchronizes that file, closes it, then replaces the destination. Failed preparation or replacement keeps the original bytes; cleanup removes only the save's temporary file, on a best-effort basis. Existing `.tmp` files, directories and links are untouched. A new document gets the default mode (`0666` less the umask). When replacing an existing regular file, the Unix temporary file starts with mode `0600` and then takes the existing file's basic permissions. Serialization and format support are unchanged. Native `io::write_file` (Print to PDF, `file.printPdf`; Export Page as PNG, `file.exportPng`) writes through the same path (#1355). If the folder refuses the temporary file with a permission error (macOS privacy protection, a read-only folder; #2138), an existing destination is never written in place: it stays byte for byte as it was, and the error says to use Save As to another folder or to give WordCraft access to the folder (on macOS, System Settings › Privacy & Security › Files and Folders). Only a destination that doesn't exist yet is created directly (exclusively, so nothing already there is opened); a failed direct write removes it again. Unit tests cover a read-only folder on Unix; the macOS privacy case is not yet confirmed on a Mac. A folder that keeps no Unix modes refuses to set one with "Operation not supported" (a NAS share mounted by GVFS, `smb://` in the Linux file manager, refuses every mode change; #3003): the save goes on without copying the mode, since there is none to keep; any other failure to set it still stops the save and keeps the original. Unit tests cover the refusal; not yet confirmed on a real GVFS share. Synthetic CLI cases and `engine/tests/it/safe_save.rs` cover occupied names, links, blocked replacement, missing directories, Save As session state, password-protected saves and original-file protection on macOS. Unit tests inject preparation errors and interleave two prepared saves. Independent saves use separate files; the last successful replacement wins. This is not document locking or conflict detection. Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full conditions, power loss and network filesystems are not verified by these tests. The containing directory is not explicitly synchronized. Cleanup can fail when storage or permissions change, and process termination can leave a temporary file. No Word observation was needed for this filesystem change; it makes no new format-fidelity claim. +> **Three-column preset update:** 2026-10-15 · the header/footer `blankThree` preset creates an ordinary fixed-layout, borderless table with three equal cells and a trailing story paragraph (#2197). Regression checks cover cell text, width, left/center/right alignment, independent bold formatting and two DOCX save/reopen cycles. Editing guides are UI overlays and add no saved borders. Native Word interoperability is not established by these synthetic checks; readiness estimates unchanged. + ## What decides beta for formats 1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with @@ -320,6 +322,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-16 | trivial | A `w:framePr` in `w:pPrDefault` (Apple Pages writes a sizeless one at the top left) frames no paragraph and isn't written back, as Word ignores it; headers, footers and table rows of such files lay out as in Word (#2360) | | 2026-10-15 | trivial | Native .json writes pictures and kept parts as base64 text instead of arrays of numbers (a 27 MiB picture: 36 MiB instead of 286 MiB); files with the old arrays still open (#586 follow-up) | | 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) | +| 2026-10-15 | trivial | Three-column header/footer preset uses ordinary DOCX table cells; synthetic text, formatting, width and nil-border round-trip coverage (#2197) | | 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) | | 2026-10-15 | trivial | ODT headers and footers (`style:header`/`-left`/`-first`, `style:footer`/…) read from each master page, so each section shows its own (#863) | diff --git a/docs/gaps.md b/docs/gaps.md index ac06c115d..c5fb26799 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -91,6 +91,8 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc blocking gaps, and closing them lifts file formats, layout, stability and UI enough to take the full number from ~60% to ~75%. Everything else is depth on the way to full parity. +> **Three-column preset update:** 2026-10-15 · the header/footer preset uses editable table cells and screen-only editing guides (#2197). Synthetic command, undo/redo and repeated DOCX save/reopen checks cover this preset; broader template/layout gaps and readiness estimates remain unchanged. + ## By kind List toggles now recognise numbering inherited from paragraph styles (related to #3459). This closes that command-level case; the broader issue and readiness estimates are unchanged. See [typing-parity.md](typing-parity.md) for the original-fixture and round-trip coverage. @@ -223,6 +225,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | 2026-10-16 | trivial | #10 narrowed: pictures and shapes without text stories preview rotation while dragging (#2277); remaining object gaps and estimates unchanged | | 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | +| 2026-10-15 | trivial | Three-column header/footer preset uses a borderless table with editing guides (#2197); broader gaps and estimates unchanged | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-16 | trivial | Gap #20: opening a DOCX no longer copies its pictures | | 2026-10-12 | minor | Gap #24 default shortcuts expanded (140 commands on macOS / 158 on Linux/Windows); stale gaps #9 (DOCX agile encryption already works, only encrypted .doc missing) and #15 (DOCX bibliography sources already saved, only desktop integrations missing) updated | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index a9c5c4944..c502dd6fb 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -114,6 +114,8 @@ formatting of the text before the cursor. Not yet compared side by side in Word. | Change Case (Shift+F3, Home › Aa) (tracking on) | Word shows no insertion or deletion markup (checked with Formatting markup set to none, so Word may record it as a hidden formatting revision). **Differs:** WordCraft records the changed letters as a tracked deletion of the old and insertion of the new, since case isn't a run property a formatting revision could hold; Reject All restores the old case, Accept All keeps the new. Your own tracked insertion is rewritten in place; already-deleted text is left alone and doesn't count (with tracking off too), so changing case again (or Shift+F3) works from the visible text. Refused in content locked against editing | `tests_revision_checks::tracked_change_case_rejects_back_to_the_original_case`, `tracked_change_case_of_own_insertion_stays_an_insertion`, `tracked_change_case_twice_reads_only_the_live_text`, `tracked_change_case_handles_sharp_s_and_mixed_formatting`, `change_case_refuses_content_locked_against_editing` | | Unlink Fields (Ctrl+Shift+F9) (tracking on) | not tracked, like Word: the field's result stays as ordinary text. Refused in content locked against editing | `tests_revision_checks::tracked_unlink_keeps_the_result_as_plain_text`, `unlink_refuses_a_field_in_content_locked_against_editing` | +> **Three-column preset update:** 2026-10-15 · `blankThree` headers/footers start the caret in the first of three table cells (#2197). `text.tab` reaches the next cell and `text.insert` edits it through the existing command path. `crates/engine/tests/it/three_column_headers.rs` checks cell navigation, independent formatting, preset/text undo/redo and repeated DOCX save/reopen. + ## Editing keys Word's documented keyboard behaviour (Microsoft's shortcut list, feature names only); the @@ -435,6 +437,7 @@ again. | 2026-10-19 | trivial | Ctrl+Tab (⌥Tab on macOS) types a tab character in a table cell (#3137) | | 2026-10-16 | trivial | AltGr on Linux (Wayland) types ś, ł, ż… instead of opening KeyTips: the modifiers event without Alt that follows the Right Alt key no longer reads as Alt let go, and a bare AltGr tap doesn't open KeyTips (#2388) | | 2026-10-20 | trivial | AutoFormat As You Type: optional "Add a period with double-space" (off by default), undoable (#2848) | +| 2026-10-15 | trivial | Three-column header/footer preset: first-cell caret, Tab navigation, independent formatting and undo/redo regression coverage (#2197) | | 2026-10-18 | trivial | Typing over a selection takes the first selected character's formatting, also when the selection was made right to left (#2879) | | 2026-10-17 | trivial | A table inserted in a right-to-left paragraph is a right-to-left table, so Tab moves to the cell on the left (#2634) | | 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 49e532077..7cbb9de46 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -62,6 +62,8 @@ Document title opens Rename Document; the account badge opens Options › Genera | Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, also onto an empty line after Enter or Shift+Enter (#1119); File › Home and New's templates and recent documents are buttons named by their caption or file name, with the path when two file names are the same (#3176); not yet tried with real screen readers | full | 50% | 5–8 | | Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization | +> **Header/footer preset update:** 2026-10-15 · Three columns creates a one-row, three-cell borderless table (#2197). The active header/footer story has screen-only cell guides while editing, even with View Gridlines off. Closing the story removes automatic guides; explicit View Gridlines still outlines every table. No document border is changed by showing these guides. Readiness estimates unchanged. + Save Changes exposes the document-specific question as an alert-dialog name and the loss warning plus any failed-save explanation as its description (#1015). Full-app AccessKit coverage checks the focused choice's dialog ancestry, all three choices and cancellation. This is accessibility-tree evidence; native Windows screen-reader and spoken VoiceOver verification remain pending. Visual layout, wording and save policy are unchanged. The Language dialog reads effective character formatting, including document defaults, paragraph styles and character styles. Accepting it unchanged keeps inherited language and no-proof choices (#2717); explicit direct formatting still takes precedence. Other proofing and Windows-specific reports remain separate. @@ -105,6 +107,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-20 | trivial | The scrollbar page tip stays on one line: it broke into two or three lines ("Page 10 of" / "40") as the page number grew during a drag (#3341) | | 2026-10-19 | trivial | Format Page Numbers (Insert › Header & Footer › Page Number ▸ Format Page Numbers…, #1113): the dialog sets the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and whether the pages number on from the section before or start at a number; the ribbon item did nothing before (a plain command call that also cleared the section's start), and `layout.pageNumberFormat` keeps the start when `start` is left out and takes null to continue | | 2026-10-16 | trivial | KeyTips: AltGr (Right Alt with Level 3 Shift on Linux) is not an Alt tap, held or tapped, so AltGr+letter types instead of reaching the badges (#2388) | +| 2026-10-15 | trivial | Three-column header/footer table preset and automatic screen-only cell guides while editing (#2197) | | 2026-10-18 | trivial | Long navigation headings wrap onto further lines inside the pane instead of ending in an ellipsis; the row grows with the text and the pane still resizes (#759, #3015) | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-18 | trivial | Tables: a single selected cell's border drags in its row only (Select Cell, left-edge arrow, triple-click); checked against Word for Mac 16.113.4 | From 1b2f7a8c09f0fc382b769436911e2d3d248659ed Mon Sep 17 00:00:00 2001 From: SlavenDj <53652906+SlavenDj@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:39:07 +0100 Subject: [PATCH 5/6] #1845: filter-as-you-type command search in Customize Ribbon, QAT and Keyboard pickers (#1848) # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # interactive rebase in progress; onto d588e0b8 # Last command done (1 command done): # pick 6a1268ea # #1845: filter-as-you-type command search in Customize Ribbon, QAT and Keyboard pickers # No commands remaining. # You are currently rebasing branch 'feat/1845-command-filter' on 'd588e0b8'. # # Changes to be committed: # modified: crates/ui-egui/src/dialogs_keyboard.rs # modified: crates/ui-egui/src/dialogs_ribbon.rs # modified: docs/ui-parity.md # # Conflicts: # docs/ui-parity.md --- crates/ui-egui/src/dialogs_keyboard.rs | 71 +++++++++++++++++++++++++- crates/ui-egui/src/dialogs_ribbon.rs | 49 ++++++++++++++++-- docs/ui-parity.md | 5 +- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs_keyboard.rs index 156e64855..748fac2e8 100644 --- a/crates/ui-egui/src/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs_keyboard.rs @@ -23,6 +23,9 @@ pub struct KeyboardForm { pub category: String, /// The selected command id. pub command: String, + /// Filter-as-you-type query for the command list (#1845); UI-only dialog state, ignored + /// while empty. Never persisted and never a command parameter. + pub filter: String, /// The key picked in "Keys for this command" (for Remove). pub current: String, /// The key combination pressed in "New shortcut", canonical (`Mod+Shift+K`). @@ -72,6 +75,32 @@ pub(crate) fn commands(app: &WordApp, category: &str) -> Vec<(&'static str, Stri out } +/// The commands to list for `category`, narrowed by `query` (#1845): an empty query keeps the +/// category scope; any other text searches every registered command by its id and its English or +/// translated label, case-insensitive (the id lets "file.save" or "save" find Save). Sorted as +/// [`commands`]. +pub(crate) fn matching_commands(app: &WordApp, category: &str, query: &str) -> Vec<(String, String)> { + let q = crate::i18n::lowercase(query.trim()); + if q.is_empty() { + return commands(app, category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + } + let mut out: Vec<(String, String)> = app + .session + .registry + .all() + .iter() + .filter(|s| s.id.to_lowercase().contains(&q) || s.label.to_lowercase().contains(&q) || crate::i18n::lowercase(tl!(s.label)).contains(&q)) + .map(|s| (s.id.to_string(), tl!(s.label).to_string())) + .collect(); + out.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()).then(a.0.cmp(&b.0))); + out +} + +/// Keep `wanted` when it is still listed, else fall back to the first row (#1845). +pub(crate) fn select_in(rows: &[(String, String)], wanted: &str) -> String { + if rows.iter().any(|(id, _)| id == wanted) { wanted.to_string() } else { rows.first().map(|(id, _)| id.clone()).unwrap_or_default() } +} + /// Only combinations with Mod, Ctrl or Alt, or function keys, can be assigned from the dialog /// (plain letters and Shift+letter type text). fn assignable(key: &str) -> bool { @@ -161,15 +190,21 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm) let (left, right) = (170.0, 300.0); heading(ui, "Choose a command"); + ui.add(egui::TextEdit::singleline(&mut f.filter).hint_text(tl!("Search")).desired_width(left + right + 8.0)); let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect(); cats.push((ALL.to_string(), tl!("All Commands").to_string())); - let cmds: Vec<(String, String)> = commands(app, &f.category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + let cmds: Vec<(String, String)> = matching_commands(app, &f.category, &f.filter); + f.command = select_in(&cmds, &f.command); + if cmds.is_empty() { + ui.label(egui::RichText::new(tl!("No matching commands")).weak()); + } ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.label(tl!("Categories:")); if let Some(c) = list(ui, "kb_categories", vec2(left, 170.0), &cats, &f.category) { f.category = c; - f.command = commands(app, &f.category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default(); + f.filter.clear(); + f.command = matching_commands(app, &f.category, &f.filter).first().map(|(id, _)| id.clone()).unwrap_or_default(); f.current.clear(); } }); @@ -450,4 +485,36 @@ mod tests { assert_eq!(h.state().1.command, first_of_home.0); assert!(row_in_view(&h, &first_of_home.1), "the new selection {} is in view", first_of_home.0); } + + /// #1845: the command filter searches every command by label or id, ignoring the category. + #[test] + fn command_filter_searches_all_commands() { + use super::{matching_commands, select_in}; + + let app = || WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + let a = app(); + // Empty query keeps the category scope. + let file_cmds = matching_commands(&a, "File", ""); + assert!(!file_cmds.is_empty()); + assert!( + file_cmds + .iter() + .all(|(id, _)| crate::i18n::location(a.session.registry.get(id).map(|s| s.location).unwrap_or_default()).starts_with("File")) + ); + assert!(file_cmds.iter().any(|(id, _)| id == "file.save")); + // A label finds its command from another category's scope. + let bold = matching_commands(&a, "File", "bold"); + assert!(bold.iter().any(|(id, _)| id == "format.bold")); + // Matching is case-insensitive and covers ids. + assert!(matching_commands(&a, "File", "SAVE").iter().any(|(id, _)| id == "file.save")); + assert!(matching_commands(&a, "File", "format.b").iter().any(|(id, _)| id == "format.bold")); + // Nothing matches: empty rows and an empty selection. + let none = matching_commands(&a, "File", "zzz-no-such-command"); + assert!(none.is_empty()); + assert_eq!(select_in(&none, "file.save"), ""); + // The selection survives when still listed, else falls back to the first row. + assert_eq!(select_in(&bold, "format.bold"), "format.bold"); + let first = bold.first().map(|(id, _)| id.clone()).unwrap_or_default(); + assert_eq!(select_in(&bold, "file.save"), first); + } } diff --git a/crates/ui-egui/src/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs_ribbon.rs index 1e0e7c2a7..094e1b277 100644 --- a/crates/ui-egui/src/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs_ribbon.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use crate::WordApp; use crate::dialogs::Dialog; -use crate::dialogs_keyboard::{categories, commands, list}; +use crate::dialogs_keyboard::{categories, commands, list, matching_commands, select_in}; use crate::theme::semibold; /// The dialog's two pages. @@ -39,6 +39,9 @@ pub struct RibbonForm { /// The left list: a category (empty for All Commands) and the command picked in it. pub category: String, pub command: String, + /// Filter-as-you-type query for the command list (#1845); UI-only dialog state, ignored + /// while empty. Never persisted and never a command parameter. + pub filter: String, /// The item picked in the ribbon tree. pub selected: TreeItem, /// The command picked in the Quick Access Toolbar list. @@ -97,16 +100,22 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b }); ui.separator(); let (left, right, h) = (150.0, 230.0, 300.0); + ui.add(egui::TextEdit::singleline(&mut f.filter).hint_text(tl!("Search")).desired_width(left + right + 8.0)); let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect(); cats.push((String::new(), tl!("All Commands").to_string())); - let cmds: Vec<(String, String)> = commands(app, &f.category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + let cmds: Vec<(String, String)> = matching_commands(app, &f.category, &f.filter); + f.command = select_in(&cmds, &f.command); + if cmds.is_empty() { + ui.label(egui::RichText::new(tl!("No matching commands")).weak()); + } ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.label(egui::RichText::new(tl!("Choose a command")).font(semibold(12.5))); ui.horizontal_top(|ui| { if let Some(c) = list(ui, "rb_categories", vec2(left, h), &cats, &f.category) { f.category = c; - f.command = commands(app, &f.category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default(); + f.filter.clear(); + f.command = matching_commands(app, &f.category, &f.filter).first().map(|(id, _)| id.clone()).unwrap_or_default(); } if let Some(c) = list(ui, "rb_commands", vec2(right, h), &cmds, &f.command) { f.command = c; @@ -466,6 +475,40 @@ mod tests { assert_eq!(a.ui.tab, "Insert", "a hidden tab isn't shown"); } + /// #1845: the shared command picker (Ribbon and Quick Access Toolbar pages) filters + /// as you type, and Add works from a filtered selection. + #[test] + fn command_filter_narrows_the_picker_and_add_works() { + use crate::dialogs_keyboard::{matching_commands, select_in}; + + let mut a = app(); + a.run("tools.customizeRibbon", json!({})).unwrap(); + assert!(matches!(a.dialog, Some(Dialog::CustomizeRibbon { .. }))); + if let Some(Dialog::CustomizeRibbon { form }) = &mut a.dialog { + form.filter = "bold".to_string(); + } + let (category, filter, current) = match &a.dialog { + Some(Dialog::CustomizeRibbon { form }) => (form.category.clone(), form.filter.clone(), form.command.clone()), + _ => panic!("expected the Customize Ribbon dialog"), + }; + // Empty filter keeps the category scope. + let unfiltered = matching_commands(&a, &category, ""); + assert!(!unfiltered.is_empty()); + // Typing searches every command, ignoring the category. + let cmds = matching_commands(&a, &category, &filter); + assert!(cmds.iter().any(|(id, _)| id == "format.bold")); + assert!(cmds.len() < a.session.registry.all().len()); + // The dialog's Add >> adds the filtered pick to the Quick Access Toolbar. + let picked = select_in(&cmds, ¤t); + assert!(!picked.is_empty()); + if let Some(Dialog::CustomizeRibbon { form }) = &mut a.dialog { + form.command = picked.clone(); + } + a.run("tools.customizeRibbon", json!({"qatAdd": picked})).unwrap(); + assert!(a.session.ribbon.qat().contains(&picked.as_str())); + assert!(a.session.ribbon.qat().contains(&"format.bold")); + } + /// #376: the Quick Access Toolbar's … button opens its menu, and the toolbar shows the user's /// commands. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7cbb9de46..4861ecc48 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (filter-as-you-type command search in the Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers, #1845; previously trivial: Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -19,7 +19,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Mini-toolbar on selection and text context menu | ✅ (#43, two rows #535; Recently Used Fonts in its font box #2068; the text context menu includes the same toolbar at a caret or selection, #2959) | ✅ | 80% | 1–2 | | Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); Shape Fill › Gradient: Light and Dark Variations of the shape's colour, More Gradients… (#1500); no eyedropper | ✅ | 90% | 1 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Its menu ends with Hide Quick Access Toolbar, which takes it out of the title bar; Show Quick Access Toolbar on the ribbon's right-click menu, or in Customize Ribbon › Quick Access Toolbar, brings it back; kept between runs; `ui.quickAccessToolbar` for scripts (#1945). Not shown below the ribbon | customizable | 92% | 0.5 | -| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | +| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file; filter-as-you-type command search in all three pickers (#1845) | ✅ | 85% | 1–2 | | Keyboard shortcuts | **137** commands bound in `CommandSpec::key`, 156 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **54** (`Dialog` enum: 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 (every part: font, borders, shading, cell alignment, #714), 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); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) 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, 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); Format Shape (#1500: Fill only: no fill, solid colour and transparency, gradient presets, type, direction, angle, stops bar, per-stop colour, position and transparency, rotate with shape; opened from Shape Fill › Gradient › More Gradients… and right-click › Format Shape…; drags preview live and undo in one step; no Line, Effects or Layout sections, no Format Picture) open | all | 70% | 4–6 | @@ -308,3 +308,4 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 2026-10-15 | trivial | Double-clicking a page number in a later page's footer opens the footer there: the first click no longer moves the caret to the body text and scrolls the view up to it (#2103) | | 2026-10-20 | trivial | File › Home and New: each template (thumbnail and caption) and each recent document is one button named by its caption or file name (with the path when two recent file names are the same), so a screen reader announces it instead of unnamed text (#3176) | | 2026-10-23 | trivial | Font dialog: "NaN" or "inf" in the Size box previews the text at 12 pt instead of stopping the app while drawing the preview (#3663) | +| 2026-10-25 | trivial | Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers: filter-as-you-type command search across all commands (label or id, case-insensitive); clearing restores the category scope (#1845) | From 49731f3faad534ceed38746f1af1a9d25dc6f6f2 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sun, 25 Oct 2026 16:44:31 +0100 Subject: [PATCH 6/6] =?UTF-8?q?#2982:=20File=20=E2=96=B8=20Info=20Custom?= =?UTF-8?q?=20Document=20Properties=20dialog=20(#2997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/engine/src/cmd/file.rs | 30 +- crates/engine/src/tests.rs | 46 +++ crates/ui-egui/src/backstage.rs | 9 + crates/ui-egui/src/dialogs.rs | 530 ++++++++++++++++++++++++++++++++ docs/gaps.md | 4 +- docs/ui-parity.md | 7 +- 7 files changed, 614 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7ff517130..e9e648cf1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,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-25 · **Change:** trivial (progress log: regex Replace expands capture groups, #4177; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: 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); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; 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-25 · **Change:** trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: regex Replace expands capture groups, #4177; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: 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); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; 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-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; 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. @@ -218,6 +218,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-19 | A table-of-contents entry whose text just fits no longer leaves its page number alone at the start of the next line: the tab goes there with it, leader and all (#1840). | | 2026-10-19 | A Thai run with Latin letters and digits in a missing Thai font is drawn in one face on Linux too (#1549): the fallback no longer stops at Noto Sans Thai, which has no Latin letters, but finds an installed font covering the whole run (IBM Plex Sans Thai, say) and loads its whole family, so regular text gets the Regular face. Concurrent lookups no longer cache a miss. | | 2026-10-19 | Word signature lines (Insert › Signature Line) survive a save: a .docx saved by WordCraft keeps each one as a signature line, still signable in Word, with its suggested signer, instead of turning it into a plain picture. | +| 2026-10-25 | Custom document properties (#2982, split from #1798): File › Info › Properties › Advanced Properties… opens a Custom Document Properties dialog that lists the document's custom properties and adds, modifies and deletes them through the name/value form (text properties only; Word's number/date/yes-no types not offered). OK is one undoable `file.properties` step and marks the document changed; Cancel discards everything; names and values are validated against the engine's limits with the error shown in the dialog. Saved properties survive a DOCX round trip. | | 2026-10-15 | A picture anchored further down a page but placed higher up it (fixed on the page, or moved up from its paragraph) has the paragraphs above its anchor wrap around it too, instead of running behind it, also when that wrap pushes the anchor onto the next page (#2048) | | 2026-10-20 | Table sorting and formulas read and write decimal points for Cuban and Peruvian Spanish (`es-CU`, `es-PE`), so `1.234` is a decimal instead of 1234; grouped-number and result-formatting regressions retain Spain's decimal comma. | | 2026-10-20 | Set Proofing Language › Set As Default persists for future blank documents and plain-text files, independently of the interface language; existing DOCX and templates keep their languages, and rejected commands retain the previous saved default (#2882). | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f3eafb5da..c40777241 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -416,15 +416,25 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult { let Some(m) = custom.as_object() else { return Err(CmdError::Params("`custom` must be an object of name → string or null".into())); }; + // Removals first: a case-only rename sends the old spelling as null and the new one + // with its value, and `set_custom_prop` matches names case-insensitively, so setting + // first would keep the old spelling (object keys are sorted, either spelling may lead). for (name, val) in m { - if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME { - return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters"))); + if val.is_null() { + s.doc.remove_custom_prop(name); } + } + for (name, val) in m { match val { - Value::Null => { - s.doc.remove_custom_prop(name); + // Removals took the name as stored above: a file opened from an older Word may + // hold one longer than today's limit, and it must still be deletable (#2982). + Value::Null => {} + Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => { + if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME { + return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters"))); + } + s.doc.set_custom_prop(name, x); } - Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => s.doc.set_custom_prop(name, x), _ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))), } } @@ -436,10 +446,12 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult { Ok(out) } -/// Longest custom property name (Word's limit). -const MAX_CUSTOM_NAME: usize = 255; -/// Largest custom property value we accept from a command. -const MAX_CUSTOM_VALUE: usize = 1024 * 1024; +/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it +/// too (#2982). +pub const MAX_CUSTOM_NAME: usize = 255; +/// Largest custom property value we accept from a command. The Custom Properties dialog +/// validates with it too (#2982). +pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024; fn info(s: &mut Session, _: &Value) -> CmdResult { let l = s.layout(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index de0a6c4eb..461d5f14a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4487,6 +4487,21 @@ fn word_count_with_an_object_selected_counts_the_document() { assert_eq!(run(&mut s, "review.wordCount", json!({}))["words"].as_u64(), Some(1)); } +/// #2982: a property imported from a file made by an older Word may have a name longer than +/// today's limit. Such a name is still deletable (the dialog sends `null`), while setting or +/// renaming a property to it stays refused. +#[test] +fn custom_properties_delete_legacy_names_but_new_names_keep_the_limit() { + let mut s = s(); + let long = "x".repeat(crate::cmd::file::MAX_CUSTOM_NAME + 1); + s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "".into() }); + let mut custom = serde_json::Map::new(); + custom.insert(long.clone(), serde_json::Value::Null); + run(&mut s, "file.properties", json!({"custom": custom})); + assert!(s.doc.custom_props.is_empty(), "the legacy name could be deleted"); + assert!(s.run("file.properties", &json!({"custom": { (long): "x" }})).is_err(), "a new or updated name keeps the limit"); +} + #[test] fn custom_properties_set_read_remove_and_undo() { let mut s = s(); @@ -4504,6 +4519,37 @@ fn custom_properties_set_read_remove_and_undo() { assert_eq!(s.doc.custom_prop("Status"), Some("draft")); } +/// #2982: a case-only rename arrives as the old spelling with null and the new one with its +/// value. The removal must run first: object keys are sorted, so `Status` sorts before +/// `status`, and setting before removing would let the removal take the new spelling too. +#[test] +fn custom_properties_case_only_rename_keeps_the_new_spelling() { + let mut s = s(); + run(&mut s, "file.properties", json!({"custom": {"status": "draft"}})); + run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "status": null}})); + let props: Vec<(&str, &str)> = s.doc.custom_props.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect(); + assert_eq!(props, [("Status", "draft")], "the property keeps the new spelling and its value"); +} + +/// #2982: what the Custom Properties dialog saves (through `file.properties`) survives a .docx +/// save and reopen. +#[test] +fn custom_properties_round_trip_through_docx() { + let mut s = s(); + run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "Reviewer": "Ada L."}})); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(back.custom_prop("Status"), Some("draft")); + assert_eq!(back.custom_prop("Reviewer"), Some("Ada L.")); + assert_eq!(back.custom_props.len(), 2); + // One call removes one and adds another; the saved file follows the document. + run(&mut s, "file.properties", json!({"custom": {"Status": null, "Owner": "Ada L."}})); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(back.custom_prop("Status"), None); + assert_eq!(back.custom_prop("Reviewer"), Some("Ada L.")); + assert_eq!(back.custom_prop("Owner"), Some("Ada L.")); + assert_eq!(back.custom_props.len(), 2); +} + /// `document_id` tells an edit from a replacement (the UI drops a "Save changes?" prompt /// about a document that has been replaced). #[test] diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 2e8a0ba6f..a8c85b546 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -649,6 +649,15 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) { if focused != app.info_editing.map(|(f, _)| f) { app.info_editing = None; } + // Advanced Properties › Custom (#2982): view and edit the document's custom properties. + ui.add_space(6.0); + if ui + .button(egui::RichText::new(tl!("Advanced Properties…")).font(medium(13.5))) + .on_hover_text(tl!("View and edit the document's custom properties.")) + .clicked() + { + let _ = app.run("ui.dialog", json!({"name": "customProperties"})); + } // Protect Document › Encrypt with Password (#55). ui.add_space(18.0); ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0))); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f8baa0a09..466bfdb23 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -558,6 +558,166 @@ pub enum Dialog { }, /// Line Numbers, Hyphenation, Language, Envelopes and Labels (#407). Layout(Box), + /// File › Info › Properties › Advanced Properties › Custom: the document's custom + /// properties (#2982). + CustomProperties(Box), +} + +/// The Custom tab of File › Info › Properties › Advanced Properties (#2982): the document's +/// custom properties, edited as a working copy. OK sends the whole difference from what the +/// dialog opened with as one undoable `file.properties` step (marking the document changed and +/// refreshing File › Info); Cancel sends nothing. Text properties only for now: Word's +/// number/date/yes-no types aren't offered, and a property of another type keeps its stored +/// form — the dialog lists it but refuses to change it (Delete still removes one outright). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomPropertiesForm { + /// The working copy, in document order. + pub props: Vec, + /// What the document held when the dialog opened; OK sends only what differs. + start: Vec, + /// The row the list selection is on. + pub selected: usize, + /// The name/value boxes under the list. + pub name: String, + pub value: String, + /// The last error to show under the form (a refused name, a failed command). + pub message: String, +} + +impl CustomPropertiesForm { + pub(crate) fn read(app: &WordApp) -> Self { + let props = app.session.doc.custom_props.clone(); + let mut form = Self { props: props.clone(), start: props, selected: 0, name: String::new(), value: String::new(), message: String::new() }; + // The first row is highlighted, so its fields are ready to modify, as in Word. + form.select(0); + form + } + + /// Clicking a row: it fills the name and value boxes, as in Word. + pub(crate) fn select(&mut self, i: usize) { + self.selected = i; + self.message.clear(); + match self.props.get(i) { + Some(p) => { + self.name = p.name.clone(); + self.value = p.value.clone(); + } + None => { + self.name.clear(); + self.value.clear(); + } + } + } + + /// The typed name and value, validated against the engine's own limits + /// (`file.properties`, which refuses them the same way). + fn form(&self) -> Result<(String, String), String> { + let max_name = wordcraft_engine::cmd::file::MAX_CUSTOM_NAME; + let name = self.name.trim(); + if name.is_empty() || name.chars().count() > max_name { + return Err(crate::i18n::fmt(tl!("Property names are 1–{max} characters."), &[("max", &max_name.to_string())])); + } + if self.value.len() > wordcraft_engine::cmd::file::MAX_CUSTOM_VALUE { + let max = wordcraft_engine::cmd::file::MAX_CUSTOM_VALUE; + return Err(crate::i18n::fmt(tl!("Property values are at most {max} bytes."), &[("max", &max.to_string())])); + } + Ok((name.to_string(), self.value.clone())) + } + + /// Add: the typed name and value join the list, replacing a property of the same name + /// (names compare case-insensitively, as in Word and the engine). + pub(crate) fn add(&mut self) { + match self.form() { + Ok((name, value)) => { + self.message.clear(); + match self.props.iter().position(|p| p.name.eq_ignore_ascii_case(&name)) { + Some(i) if self.props[i].kind != "lpwstr" => { + self.message = tl!("Only text properties can be changed.").to_string(); + } + Some(i) => { + self.props[i].value = value; + self.selected = i; + } + None => { + self.props.push(wordcraft_doc::CustomProp { name, kind: "lpwstr".into(), value }); + self.selected = self.props.len().saturating_sub(1); + } + } + } + Err(m) => self.message = m, + } + } + + /// Modify: the selected property takes the typed name and value; a different name moves it + /// there (replacing a property already of that name). A refused form keeps the list as it + /// was. + pub(crate) fn modify(&mut self) { + let Some(old) = self.props.get(self.selected).cloned() else { + self.message = tl!("Select a property to change.").to_string(); + return; + }; + if old.kind != "lpwstr" { + self.message = tl!("Only text properties can be changed.").to_string(); + return; + } + let (name, value) = match self.form() { + Ok(f) => f, + Err(m) => { + self.message = m; + return; + } + }; + if self.props.iter().enumerate().any(|(i, p)| i != self.selected && p.kind != "lpwstr" && p.name.eq_ignore_ascii_case(&name)) { + self.message = tl!("Only text properties can be changed.").to_string(); + return; + } + self.message.clear(); + self.props.remove(self.selected); + match self.props.iter().position(|p| p.name.eq_ignore_ascii_case(&name)) { + Some(i) => { + self.props[i].value = value; + self.selected = i; + } + None => { + let i = self.selected.min(self.props.len()); + self.props.insert(i, wordcraft_doc::CustomProp { name, kind: old.kind.clone(), value }); + self.selected = i; + } + } + } + + /// Delete: the selected property leaves the list. + pub(crate) fn delete(&mut self) { + if self.selected < self.props.len() { + self.props.remove(self.selected); + self.select(self.selected.min(self.props.len().saturating_sub(1))); + } + } + + /// The one `file.properties` call OK makes: a property gone since the dialog opened is + /// null, one added or changed is its value. Untouched properties aren't sent, so one of + /// another type (Zotero's raw XML, a number) keeps its stored form. A rename that only + /// changes case sends the old spelling as null too, so the stored spelling follows it. + pub(crate) fn params(&self) -> Value { + let mut custom = serde_json::Map::new(); + for p in &self.start { + match self.props.iter().find(|q| q.name.eq_ignore_ascii_case(&p.name)) { + Some(q) if q.name == p.name => {} + _ => { + custom.insert(p.name.clone(), Value::Null); + } + } + } + for p in &self.props { + let changed = + self.start.iter().find(|q| q.name.eq_ignore_ascii_case(&p.name)).is_none_or(|was| was.name != p.name || was.value != p.value); + if changed { + custom.insert(p.name.clone(), json!(p.value)); + } + } + json!({"custom": custom}) + } } /// The Table Properties dialog's fields. Lengths are in the interface unit @@ -1021,6 +1181,7 @@ impl Dialog { Dialog::NoteOptions { .. } => "noteOptions", Dialog::Templates { .. } => "templates", Dialog::Organizer { .. } => "organizer", + Dialog::CustomProperties(..) => "customProperties", Dialog::Layout(d) => d.name(), } } @@ -1303,6 +1464,7 @@ impl Dialog { "noteOptions" => Dialog::NoteOptions { form: Box::new(crate::dialogs_notes::NoteForm::read(app)) }, "templates" => Dialog::Templates { form: Box::new(crate::dialogs_templates::TemplatesForm::read(app)) }, "organizer" => Dialog::Organizer { form: Box::new(crate::dialogs_templates::OrganizerForm::read(app, None)) }, + "customProperties" => Dialog::CustomProperties(Box::new(CustomPropertiesForm::read(app))), "controlProperties" => match crate::dialogs_controls::ControlForm::read(app) { Some(form) => Dialog::Control { form: Box::new(form) }, None => { @@ -1495,6 +1657,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::NoteOptions { .. } => "Footnote and Endnote", Dialog::Templates { .. } => "Templates and Add-ins", Dialog::Organizer { .. } => "Organizer", + Dialog::CustomProperties { .. } => "Custom Document Properties", Dialog::Layout(d) => d.title(), }; let id = egui::Id::new(("dialog", title)); @@ -2985,6 +3148,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { Dialog::NoteOptions { form } => crate::dialogs_notes::note_options(app, ui, form), Dialog::Templates { form } => crate::dialogs_templates::templates(app, ui, form), Dialog::Organizer { form } => crate::dialogs_templates::organizer(app, ui, form), + Dialog::CustomProperties(form) => custom_properties(app, ui, form), Dialog::Layout(d) => crate::dialogs_layout::body(app, ui, d), Dialog::FindRecipient { text, message } => { ui.horizontal(|ui| { @@ -3784,6 +3948,84 @@ fn close_button(ui: &mut Ui) -> bool { close } +/// A custom property's one-line list preview: its first line, at most 80 characters, with an +/// ellipsis when either is cut. The Value box keeps the whole value. +fn custom_value_preview(value: &str) -> String { + const MAX: usize = 80; + let first = value.lines().next().unwrap_or(""); + let mut chars = first.chars(); + let mut shown: String = chars.by_ref().take(MAX).collect(); + if chars.next().is_some() || first.len() < value.len() { + shown.push('…'); + } + shown +} + +/// The Custom tab of File › Info › Properties › Advanced Properties (#2982): the document's +/// custom properties with Add, Modify and Delete over a name/value form. OK is one undo step +/// (one `file.properties` call), Cancel leaves the document as it was. +fn custom_properties(app: &mut WordApp, ui: &mut Ui, f: &mut CustomPropertiesForm) -> bool { + let t = Tokens::get(ui.ctx()); + let mut clicked = None; + let rows = f.props.len().clamp(1, 8); + let row_h = ui.spacing().interact_size.y + ui.spacing().item_spacing.y; + egui::ScrollArea::vertical().id_salt("custom_props").max_height(rows as f32 * row_h + 4.0).auto_shrink([false, true]).show(ui, |ui| { + if f.props.is_empty() { + ui.label(egui::RichText::new(tl!("This document has no custom properties.")).color(t.text_dim)); + } + for (i, p) in f.props.iter().enumerate() { + let row = ui.selectable_label(f.selected == i, format!("{} = {}", p.name, custom_value_preview(&p.value))); + if row.clicked() { + clicked = Some(i); + } + } + }); + if let Some(i) = clicked { + f.select(i); + } + ui.horizontal(|ui| { + if ui.button(tl!("Add")).clicked() { + f.add(); + } + if ui.add_enabled(f.selected < f.props.len(), egui::Button::new(tl!("Modify"))).clicked() { + f.modify(); + } + if ui.add_enabled(f.selected < f.props.len(), egui::Button::new(tl!("Delete"))).clicked() { + f.delete(); + } + }); + ui.separator(); + egui::Grid::new("custom_props_form").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + let name = ui.label(tl!("Name:")); + ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(300.0).char_limit(wordcraft_engine::cmd::file::MAX_CUSTOM_NAME)) + .labelled_by(name.id); + ui.end_row(); + let value = ui.label(tl!("Value:")); + ui.add(egui::TextEdit::singleline(&mut f.value).desired_width(300.0)).labelled_by(value.id); + ui.end_row(); + }); + if !f.message.is_empty() { + ui.colored_label(ui.visuals().error_fg_color, f.message.as_str()); + } + ui.label(egui::RichText::new(tl!("Text properties only: Word's number, date and yes-no types aren't offered.")).small().weak()); + let (ok, cancel) = buttons(ui, tl!("OK")); + if cancel { + return true; + } + if ok { + let params = f.params(); + if params["custom"].as_object().is_some_and(serde_json::Map::is_empty) { + return true; + } + // One call: one undo step, the document marked changed, File › Info reads it afresh. + match app.run("file.properties", params) { + Ok(_) => return true, + Err(e) => f.message = e, + } + } + false +} + /// A style's description: its type, the formatting it sets and what it is based on. fn describe(st: &Value) -> String { let f = st.get("format").cloned().unwrap_or_default(); @@ -5391,4 +5633,292 @@ mod tests { tab(&mut h, true); assert_eq!(levels(h.state()), Some([0, 1, 2, 2, 3, 3].to_vec()), "Shift+Tab promotes them back"); } + + /// Type into a dialog text field, replacing what is there (as a user would: click, select + /// all, type over). + fn set_text_field(h: &mut egui_kittest::Harness<'_, WordApp>, label: &str, text: &str) { + use egui_kittest::kittest::Queryable; + h.get_by_role_and_label(egui::accesskit::Role::TextInput, label).click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + h.run_steps(1); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, label).type_text(text); + h.run_steps(2); + } + + /// #2982: the list keeps its full values but previews only the first line, at most 80 + /// characters, with an ellipsis when either is cut. + #[test] + fn custom_property_previews_stay_on_one_bounded_line() { + assert_eq!(custom_value_preview("short"), "short"); + assert_eq!(custom_value_preview(&"x".repeat(80)), "x".repeat(80), "exactly the limit is not cut"); + assert_eq!(custom_value_preview(&"x".repeat(81)), format!("{}…", "x".repeat(80))); + assert_eq!(custom_value_preview("first\nsecond"), "first…", "a later line is cut"); + let wide = "é".repeat(80); + assert_eq!(custom_value_preview(&wide), wide, "80 characters, not 80 bytes"); + let wide = "é".repeat(81); + assert_eq!(custom_value_preview(&wide).chars().count(), 81, "80 characters plus the ellipsis"); + assert!(custom_value_preview(&wide).ends_with('…')); + } + + /// #2982: File › Info's Advanced Properties… opens the Custom Properties dialog; it lists + /// the document's custom properties, and Add / Modify / Delete edit a working copy that OK + /// applies as one undoable `file.properties` step, Cancel discarding it. + #[test] + fn the_custom_properties_dialog_adds_edits_and_deletes_in_one_step() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft", "Owner": "Mira"}})); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + assert!(matches!(&h.state().dialog, Some(Dialog::CustomProperties(_))), "opens from File › Info"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("customProperties")); + assert!(h.query_by_label("Status = draft").is_some(), "the list shows the properties"); + // A row is selected, so the buttons act on it. + assert!(!h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").accesskit_node().is_disabled()); + assert!(!h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").accesskit_node().is_disabled()); + // Add one through the form. + set_text_field(&mut h, "Name:", "Reviewer"); + set_text_field(&mut h, "Value:", "Ada"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Reviewer = Ada").is_some(), "Add appends the typed property"); + // A duplicate name in another case replaces its value. + set_text_field(&mut h, "Name:", "status"); + set_text_field(&mut h, "Value:", "final"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Status = final").is_some(), "the duplicate replaced the value"); + // Selecting a row fills the boxes; Modify changes its value. + h.get_by_label("Reviewer = Ada").click(); + h.run_steps(2); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Name:").value().as_deref(), Some("Reviewer")); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Value:").value().as_deref(), Some("Ada")); + set_text_field(&mut h, "Value:", "Ada L."); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("Reviewer = Ada L.").is_some(), "Modify keeps the edited value"); + // Delete another property, so the modified value is saved before that one goes. + h.get_by_label("Owner = Mira").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").click(); + h.run_steps(2); + assert!(h.query_by_label("Owner = Mira").is_none(), "Delete removes the selected property"); + // OK: one call, so one undo step, marking the document changed. + let depth = h.state().session.undo_depth(); + h.state_mut().session.dirty = false; + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + let props: Vec<(String, String)> = h.state().session.doc.custom_props.iter().map(|p| (p.name.clone(), p.value.clone())).collect(); + assert_eq!(props, [("Status".to_string(), "final".to_string()), ("Reviewer".to_string(), "Ada L.".to_string())]); + assert!(h.state().session.dirty, "the document is marked changed"); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one undo step"); + // One undo brings the old values back. + let _ = h.state_mut().run("edit.undo", json!({})); + assert_eq!(h.state().session.doc.custom_prop("Status"), Some("draft"), "one undo step"); + assert_eq!(h.state().session.doc.custom_prop("Owner"), Some("Mira")); + assert_eq!(h.state().session.doc.custom_prop("Reviewer"), None); + } + + /// #2982: the highlighted first row is ready to modify: its name and value fill the boxes, + /// so clicking Modify straight away is not refused for an empty name. + #[test] + fn the_custom_properties_dialog_starts_ready_to_modify_the_first_property() { + use egui_kittest::kittest::Queryable; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft"}})); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + let role = egui::accesskit::Role::TextInput; + assert_eq!(h.get_by_role_and_label(role, "Name:").value().as_deref(), Some("Status")); + assert_eq!(h.get_by_role_and_label(role, "Value:").value().as_deref(), Some("draft")); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("Property names are 1–255 characters.").is_none(), "the prefilled name is accepted"); + assert!(h.query_by_label("Status = draft").is_some(), "Modify with no edits keeps the property"); + // A real edit through the same prefilled form reaches the document as one undo step. + let depth = h.state().session.undo_depth(); + set_text_field(&mut h, "Value:", "final"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); + h.run_steps(4); + assert_eq!(h.state().session.doc.custom_prop("Status"), Some("final")); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one undoable change"); + } + + /// #2982: a rename that changes only the case of the name is still a rename. Modify and OK + /// must send the old spelling as a removal and the new one as a value, or the stored + /// spelling never changes. + #[test] + fn the_custom_properties_dialog_keeps_a_case_only_rename() { + use egui_kittest::kittest::Queryable; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft"}})); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + // Select the property and change only the case of its name, keeping the value. + h.get_by_label("Status = draft").click(); + h.run_steps(2); + set_text_field(&mut h, "Name:", "status"); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Value:").value().as_deref(), Some("draft")); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("status = draft").is_some(), "the list shows the new spelling"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + let props: Vec<(String, String)> = h.state().session.doc.custom_props.iter().map(|p| (p.name.clone(), p.value.clone())).collect(); + assert_eq!(props, [("status".to_string(), "draft".to_string())], "the stored spelling follows the rename"); + } + + /// #2982: Cancel leaves the document as it was; empty names are refused with a message in + /// the dialog; the Name box stops at Word's limit; and the last deletion shows the empty + /// state with Modify and Delete disabled. + #[test] + fn the_custom_properties_dialog_cancel_changes_nothing_and_refuses_bad_names() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft"}})); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + let depth = h.state().session.undo_depth(); + // Empty and blank names are refused, with the message shown, keeping the list as it was. + set_text_field(&mut h, "Name:", ""); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Property names are 1–255 characters.").is_some(), "an empty name shows the message"); + set_text_field(&mut h, "Name:", " "); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Property names are 1–255 characters.").is_some(), "a whitespace name shows the message"); + assert!(h.query_by_label("Status = draft").is_some(), "nothing was added"); + // The Name box caps typing at Word's limit. + set_text_field(&mut h, "Name:", &"x".repeat(wordcraft_engine::cmd::file::MAX_CUSTOM_NAME + 20)); + let typed = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Name:").value().unwrap_or_default(); + assert_eq!(typed.chars().count(), wordcraft_engine::cmd::file::MAX_CUSTOM_NAME, "the Name box stops at the limit"); + // A valid entry, so Cancel has something to discard. + set_text_field(&mut h, "Name:", "Reviewer"); + set_text_field(&mut h, "Value:", "Ada"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Reviewer = Ada").is_some()); + // Deleting every row shows the empty state and disables Modify and Delete. + h.get_by_label("Reviewer = Ada").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").click(); + h.run_steps(2); + h.get_by_label("Status = draft").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").click(); + h.run_steps(2); + assert!(h.query_by_label("This document has no custom properties.").is_some(), "the empty list says so"); + assert!(h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").accesskit_node().is_disabled()); + assert!(h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").accesskit_node().is_disabled()); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Cancel").click(); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Cancel closes the dialog"); + let names: Vec<&str> = h.state().session.doc.custom_props.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Status"], "the document is untouched"); + assert_eq!(h.state().session.undo_depth(), depth, "no step was recorded"); + } + + /// #2982: imported non-text properties (a number, Zotero's raw XML) stay as they are: Add + /// and Modify refuse to turn one into text, while Delete still removes it. + #[test] + fn the_custom_properties_dialog_leaves_imported_non_text_properties_alone() { + use egui_kittest::kittest::Queryable; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft"}})); + app.session.doc.custom_props.push(wordcraft_doc::CustomProp { name: "Count".into(), kind: "i4".into(), value: "7".into() }); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + assert!(h.query_by_label("Count = 7").is_some(), "the imported property is listed"); + // Add with its name is refused rather than replacing it with text. + set_text_field(&mut h, "Name:", "count"); + set_text_field(&mut h, "Value:", "8"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Only text properties can be changed.").is_some(), "Add refuses the non-text name"); + assert!(h.query_by_label("Count = 7").is_some(), "it keeps its value"); + // Modify of the non-text property itself is refused. + h.get_by_label("Count = 7").click(); + h.run_steps(2); + set_text_field(&mut h, "Value:", "8"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("Only text properties can be changed.").is_some(), "Modify refuses the non-text property"); + assert!(h.query_by_label("Count = 7").is_some(), "its value is unchanged"); + // Renaming a text property onto a non-text one is refused too. + h.get_by_label("Status = draft").click(); + h.run_steps(2); + set_text_field(&mut h, "Name:", "Count"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("Only text properties can be changed.").is_some(), "a rename onto it is refused"); + assert!(h.query_by_label("Status = draft").is_some(), "the text property keeps its name"); + // Delete still removes it outright. + h.get_by_label("Count = 7").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").click(); + h.run_steps(2); + assert!(h.query_by_label("Count = 7").is_none(), "Delete removes a non-text property"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Cancel").click(); + h.run_steps(4); + let kinds: Vec<(&str, &str)> = h.state().session.doc.custom_props.iter().map(|p| (p.name.as_str(), p.kind.as_str())).collect(); + assert_eq!(kinds, [("Status", "lpwstr"), ("Count", "i4")], "Cancel leaves the imported property as it was"); + } } diff --git a/docs/gaps.md b/docs/gaps.md index c5fb26799..f7a0ad109 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,7 @@ # Where WordCraft falls short of Word > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: 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); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: 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); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -63,7 +63,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | -| 7 | **Dialog depth**: 60 modal dialogs vs Word's ~100 (Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | +| 7 | **Dialog depth**: 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4861ecc48..851b0cd47 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (filter-as-you-type command search in the Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers, #1845; previously trivial: Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: filter-as-you-type command search in the Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers, #1845; previously trivial: Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -31,11 +31,11 @@ Document title opens Rename Document; the account badge opens Options › Genera | Modal dialogs | **56** (`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 (Filter and Sort, #778; Find in list and a merged-only view, #1576), 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); 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)) 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 wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments, 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 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. The Spike: Ctrl+F3 cuts to it, Ctrl+Shift+F3 inserts it and empties it (⌘F3, ⌘⇧F3 on macOS). Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **62** (`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, Table of Figures (#3013), 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); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113; Finish & Merge records (All, Current record, or From and To by record number), opened from Finish & Merge › Edit Individual Documents… and Print Documents… (#3028)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | +| Modal dialogs | **63** (`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, Custom Document Properties (File › Info › Properties › Advanced Properties › Custom, #2982), 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, Table of Figures (#3013), 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); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113; Finish & Merge records (All, Current record, or From and To by record number), opened from Finish & Merge › Edit Individual Documents… and Print Documents… (#3028)) 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 wrap onto further lines within the pane instead of expanding it, #759, #3015), 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 wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (times in the user's own time zone, in the pane and the balloons, #2322, #2337; 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); each side-menu item has an icon, drawn in code (#1714); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 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); each side-menu item has an icon, drawn in code (#1714); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 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% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014); an In Line with Text object drags within the text as in Word: a dashed insertion cursor follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234) | ✅ | 75% | — | @@ -82,6 +82,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Custom Document Properties dialog: File › Info › Properties › Advanced Properties… opens it; the document's custom properties listed with Add, Modify and Delete over a name/value form (text properties only; Word's number/date/yes-no types not offered), OK one undoable `file.properties` step, Cancel discards; modal dialogs 62 → 63 (#2982) | | 2026-10-25 | trivial | Text Highlight Color shows the remembered colour on the ribbon and mini-toolbar buttons; No Color leaves the pen without a misleading yellow bar (#4087) | | 2026-10-24 | trivial | Read Mode: a Page color menu (None, Sepia, Inverse) beside Line spacing; the columns are drawn on white, sepia or a dark page, in place of the document's page color (`view.readPageColor`) | | 2026-10-24 | trivial | Reading Mode: Read Mode's View menu in the parity catalog; Column Width and Page Color work, Layout is missing |