M0: reconcile current-main dialogs and canvas changes with egui 0.37

This commit is contained in:
skylar taylor-barrick
2026-10-25 17:03:15 +01:00
19 changed files with 1126 additions and 54 deletions
+6 -1
View File
File diff suppressed because one or more lines are too long
+21 -9
View File
@@ -424,15 +424,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"))),
}
}
@@ -444,10 +454,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();
+28 -4
View File
@@ -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);
+54 -9
View File
@@ -1372,7 +1372,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::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>());
let mut budget = MAX_COMPARE_CELLS;
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
@@ -1408,7 +1409,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)));
@@ -1486,15 +1487,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<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
/// 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<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
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() {
@@ -1608,11 +1620,11 @@ fn tokens(s: &str, characters: bool) -> Vec<String> {
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::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>());
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), 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.
@@ -2261,12 +2273,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);
}
@@ -2284,6 +2296,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::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.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<Block>| {
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());
+46
View File
@@ -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: "<data/>".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]
+1
View File
@@ -8,3 +8,4 @@ mod recovery_format;
mod rtf_sections;
#[cfg(not(target_arch = "wasm32"))]
mod safe_save;
mod three_column_headers;
@@ -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::<f32>() - 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::<f32>() - 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(_)));
}
+47 -5
View File
@@ -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()))
}
+52 -5
View File
@@ -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<BoundedLine> {
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<dyn Backend>,
@@ -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()?;
+17
View File
@@ -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]);
}
+9
View File
@@ -656,6 +656,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)));
+79 -10
View File
@@ -942,11 +942,13 @@ fn drawing_grid(body: wordcraft_geom::Rect, step: (f32, f32)) -> (Vec<f32>, 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<Item = wordcraft_geom::Rect> + '_ {
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<StoryRef>) -> impl Iterator<Item = wordcraft_geom::Rect> + '_ {
// 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,
})
}
@@ -1168,6 +1170,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()
{
@@ -1500,9 +1504,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),
@@ -3426,6 +3431,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).
@@ -6824,12 +6830,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<f32> = gridline_cells(&page).map(|r| r.y).collect();
let ys: Vec<f32> = gridline_cells(&page, true, None).map(|r| r.y).collect();
assert_eq!(ys, [100.0, 10.0, 200.0]);
let ys: Vec<f32> = 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::<Vec<_>>();
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]
@@ -8421,6 +8433,63 @@ mod tests {
assert!(h.state().session.math.is_some(), "the new equation is being edited");
}
#[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<usize> {
nearest_page(rects, p)
}
+530
View File
@@ -558,6 +558,166 @@ pub enum Dialog {
},
/// Line Numbers, Hyphenation, Language, Envelopes and Labels (#407).
Layout(Box<crate::dialogs_layout::LayoutDialog>),
/// File › Info › Properties › Advanced Properties › Custom: the document's custom
/// properties (#2982).
CustomProperties(Box<CustomPropertiesForm>),
}
/// 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<wordcraft_doc::CustomProp>,
/// What the document held when the dialog opened; OK sends only what differs.
start: Vec<wordcraft_doc::CustomProp>,
/// 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 => {
@@ -1498,6 +1660,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::unique(("dialog", title));
@@ -2997,6 +3160,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| {
@@ -3800,6 +3964,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();
@@ -5429,4 +5671,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");
}
}
+69 -2
View File
@@ -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 {
@@ -163,15 +192,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();
}
});
@@ -453,4 +488,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);
}
}
+46 -3
View File
@@ -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, &current);
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]
+3
View File
@@ -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 `<name>.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) |
+5 -2
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -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
@@ -505,6 +507,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) |
+9 -4
View File
File diff suppressed because one or more lines are too long