From 39ab3b543367fc3c96f4cceb0f224d17df2186e5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:22:25 +0200 Subject: [PATCH] Field codes like Word's field keys (#339) (#349) Show field codes instead of results with View > Field Codes (Alt+F9) or per field with Toggle Field Codes (Shift+F9); a field showing its code is drawn as { CODE } on light grey. Edit a field's code in a small popup (double-click a field showing its code, right-click > Edit Field..., or fields.setCode), and Ctrl+F9 asks for a new field's code. F9 updates fields and shows the selected ones' results again. New commands: fields.unlink (Ctrl+Shift+F9) replaces fields with their text, fields.lock / fields.unlock (Ctrl+F11 / Ctrl+Shift+F11) keep a result through updates (already saved as w:fldLock), fields.next / fields.previous (F11 / Shift+F11) select the next or previous field, and fields.selected reports the fields in the selection. Range fields (citations) can be locked, unlinked, edited and stepped through too. The right-click menu gains Update Field, Edit Field... and Toggle Field Codes when the selection holds a field. Strings are translated in every catalog; parity catalog, ui-parity, gaps and roadmap updated. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/doc/src/para.rs | 13 +- crates/docbin/src/lib.rs | 2 +- crates/docx/src/read/story.rs | 8 +- crates/docx/src/write/story.rs | 2 +- crates/docx/tests/fixtures.rs | 8 +- crates/docx/tests/roundtrip.rs | 17 +- crates/engine/src/catalog.rs | 8 + crates/engine/src/cmd/citations.rs | 14 +- crates/engine/src/cmd/fields.rs | 356 ++++++++++++++++++++++++++++ crates/engine/src/cmd/insert.rs | 18 +- crates/engine/src/cmd/mailings.rs | 6 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/references.rs | 6 +- crates/engine/src/cmd/table.rs | 6 +- crates/engine/src/cmd/tools.rs | 2 +- crates/engine/src/sample.rs | 2 +- crates/engine/src/session.rs | 8 +- crates/layout/src/fields.rs | 10 + crates/layout/src/lib.rs | 16 +- crates/layout/src/para.rs | 21 +- crates/layout/src/table.rs | 1 + crates/layout/src/tests.rs | 9 +- crates/pdf/src/tests.rs | 7 +- crates/ui-egui/src/canvas.rs | 27 +++ crates/ui-egui/src/dialogs.rs | 40 ++++ crates/ui-egui/src/i18n/es.tsv | 9 + crates/ui-egui/src/i18n/et.tsv | 9 + crates/ui-egui/src/i18n/ja.tsv | 9 + crates/ui-egui/src/i18n/pt-br.tsv | 9 + crates/ui-egui/src/i18n/sr-latn.tsv | 9 + crates/ui-egui/src/i18n/sr.tsv | 9 + crates/ui-egui/src/i18n/uk.tsv | 9 + crates/ui-egui/src/i18n/zh-hans.tsv | 9 + crates/ui-egui/src/i18n/zh-hant.tsv | 9 + crates/ui-egui/src/keys.rs | 3 +- crates/ui-egui/src/lib.rs | 20 ++ docs/gaps.md | 3 +- docs/parity-checklist.md | 4 +- docs/ui-parity.md | 7 +- 40 files changed, 673 insertions(+), 55 deletions(-) create mode 100644 crates/engine/src/cmd/fields.rs diff --git a/ROADMAP.md b/ROADMAP.md index 81a926961..f55f88c3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields | | 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 | | 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources | | 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) | diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index d41b754d3..6cd5a9f9b 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -286,13 +286,18 @@ pub enum InlineObject { children: Vec, }, /// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…); - /// `result` the cached display text. + /// `result` the cached display text. A `locked` field keeps its result when fields update. Field { instr: String, #[serde(default)] result: String, #[serde(default)] locked: bool, + /// Shows its code (`{ PAGE }`) instead of its result, the other way round from the + /// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view + /// toggle: never written to files. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + code: bool, }, /// The start of a field whose result is ordinary content — formatted, possibly spanning /// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Citation managers' `ADDIN` @@ -1016,7 +1021,8 @@ mod tests { fn text_without_takes_ranges_in_any_order() { // Multi-byte chars at range edges, an object, and ranges unsorted, overlapping, nested and empty. let mut p = Paragraph::with_text("añb€c", CharProps::default()); - p.insert_object(3, InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false }, &CharProps::default()).unwrap(); + p.insert_object(3, InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false }, &CharProps::default()) + .unwrap(); let text = p.text.clone(); let naive = |dropped: &[std::ops::Range]| { let mut out = String::new(); @@ -1085,7 +1091,8 @@ mod tests { #[test] fn objects_track_text() { let mut p = Paragraph::with_text("ab", CharProps::default()); - p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default()).unwrap(); + p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }, &CharProps::default()) + .unwrap(); p.insert_object(0, InlineObject::BookmarkStart { name: "x".into() }, &CharProps::default()).unwrap(); assert_eq!(p.objects.len(), 2); assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. }))); diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 2388e7946..dde7ebb22 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -474,7 +474,7 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) { return; } pb.push('\u{FFFC}', CharProps::default()); - pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false }); + pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, code: false }); } /// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 48e92f41c..868693eac 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -530,7 +530,9 @@ impl Reader<'_> { pb.push_obj(InlineObject::CommentEnd { id: cid }, props); } } - "w:pgNum" => self.emit_obj(sc, pb, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false }, props), + "w:pgNum" => { + self.emit_obj(sc, pb, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false }, props) + } "w:ruby" => { if let Some(base) = k.child("w:rubyBase") { for r in base.children("w:r") { @@ -614,7 +616,7 @@ impl Reader<'_> { Item::Obj(o) => result.push_str(o.plain_text()), } } - let obj = InlineObject::Field { instr: f.instr.trim().to_string(), result, locked: f.locked }; + let obj = InlineObject::Field { instr: f.instr.trim().to_string(), result, locked: f.locked, code: false }; if let Some(parent) = sc.fields.last_mut() && !parent.spilled && parent.phase == Phase::Instr @@ -633,7 +635,7 @@ impl Reader<'_> { continue; } f.spilled = true; - pb.push_obj(InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked }, &f.props); + pb.push_obj(InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, code: false }, &f.props); for (it, p) in f.buf.drain(..) { match it { Item::Text(t) => pb.push_text(&t, &p), diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 014488137..bca25b863 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -367,7 +367,7 @@ impl Writer<'_> { None => super::math::write_equation(w, linear, *display, math), } } - InlineObject::Field { instr, result, locked } => self.field(w, instr, result, *locked, props), + InlineObject::Field { instr, result, locked, .. } => self.field(w, instr, result, *locked, props), InlineObject::FieldStart { instr, locked } => { self.rev_open(w, props); self.field_start(w, instr, *locked, props); diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 6c7adc052..ced254206 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -66,8 +66,8 @@ fn toc_complex_field_spanning_paragraphs() { let p = paras(&d); assert_eq!(p.len(), 5); assert_eq!(p[0].text, "\u{FFFC}Intro\t\u{FFFC}"); - assert_eq!(p[0].objects[0], InlineObject::Field { instr: r#"TOC \o "1-3" \h \z \u"#.into(), result: String::new(), locked: false }); - assert_eq!(p[0].objects[1], InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false }); + assert_eq!(p[0].objects[0], InlineObject::Field { instr: r#"TOC \o "1-3" \h \z \u"#.into(), result: String::new(), locked: false, code: false }); + assert_eq!(p[0].objects[1], InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false }); assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1")); assert_eq!(p[0].props.style.as_deref(), Some("TOC1")); assert_eq!(p[1].text, "Body\t2"); @@ -75,7 +75,7 @@ fn toc_complex_field_spanning_paragraphs() { assert_eq!(p[2].text, ""); assert_eq!(p[3].objects, vec![InlineObject::BookmarkStart { name: "_Toc1".into() }, InlineObject::BookmarkEnd { name: "_Toc1".into() }]); assert_eq!(p[4].plain_text(), "Page 7"); - assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false }); + assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false }); // And it writes back out and reads the same. let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); assert_eq!(again.body, d.body); @@ -499,7 +499,7 @@ fn unbalanced_fields_and_markers() { let d = read_body(body); let p = paras(&d); assert_eq!(p[0].text, "a"); - assert_eq!(p[1].objects, vec![InlineObject::Field { instr: "IF X = \"y\"".into(), result: String::new(), locked: false }]); + assert_eq!(p[1].objects, vec![InlineObject::Field { instr: "IF X = \"y\"".into(), result: String::new(), locked: false, code: false }]); assert_eq!(p[2].objects, vec![InlineObject::BookmarkStart { name: "between".into() }, InlineObject::BookmarkEnd { name: "between".into() }]); assert_eq!(p[2].plain_text(), "after"); } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index c114b238c..09aca3aa3 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -277,7 +277,8 @@ fn sections_headers_footers_round_trip() { let mut d = Document::new(); let page = |instr: &str| { let mut p = Paragraph::with_text("Page ", CharProps::default()); - p.insert_object(5, InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false }, &CharProps::default()).unwrap(); + p.insert_object(5, InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, code: false }, &CharProps::default()) + .unwrap(); p }; let h1 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header one", CharProps::default()))]); @@ -322,7 +323,7 @@ fn sections_headers_footers_round_trip() { assert_eq!(part_text(&r, secs[0].1.headers.first), "First page header"); assert_eq!(secs[0].1.headers.default, secs[1].1.headers.default, "shared header part stays shared"); let fp = r.parts.get(&secs[0].1.footers.default.unwrap()).unwrap().blocks[0].as_para().unwrap(); - assert_eq!(fp.objects, vec![InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }]); + assert_eq!(fp.objects, vec![InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }]); assert_eq!(part_text(&r, secs[1].1.footers.default), "Page 1"); assert_eq!(r.parts.get(&secs[0].1.headers.default.unwrap()).unwrap().kind, PartKind::Header); } @@ -620,7 +621,7 @@ fn toc_field_wraps_its_entries() { let end = head.len(); head.insert_object( end, - InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false }, + InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false }, &CharProps::default(), ) .unwrap(); @@ -700,7 +701,7 @@ fn toc_field_skips_nested_stories() { let end = head.len(); head.insert_object( end, - InlineObject::Field { instr: "TOC \\o \"1-1\" \\h \\z \\u".into(), result: String::new(), locked: false }, + InlineObject::Field { instr: "TOC \\o \"1-1\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false }, &CharProps::default(), ) .unwrap(); @@ -739,7 +740,7 @@ fn tracked_changes_round_trip() { let p = para_runs(&[("kept ", CharProps::default()), ("added\t", ins.clone()), ("removed", del.clone()), ("gone", both.clone())]); let mut p = p; let end = p.len(); - p.insert_object(end, InlineObject::Field { instr: "DATE".into(), result: "today".into(), locked: false }, &del).unwrap(); + p.insert_object(end, InlineObject::Field { instr: "DATE".into(), result: "today".into(), locked: false, code: false }, &del).unwrap(); d.body = vec![para_block(p.clone())]; let r = rt(&d); assert_eq!(r.revisions, d.revisions); @@ -786,9 +787,9 @@ fn tracked_paragraph_marks_round_trip() { fn fields_and_special_chars_round_trip() { let mut p = Paragraph::with_text("a\tb\nc\u{000C}d\u{000E}e\u{2011}f\u{00AD}g spaced ", CharProps::default()); let fields = [ - InlineObject::Field { instr: "PAGE".into(), result: "4".into(), locked: false }, - InlineObject::Field { instr: "DATE \\@ \"M/d/yyyy\"".into(), result: "1/2/2026".into(), locked: true }, - InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false }, + InlineObject::Field { instr: "PAGE".into(), result: "4".into(), locked: false, code: false }, + InlineObject::Field { instr: "DATE \\@ \"M/d/yyyy\"".into(), result: "1/2/2026".into(), locked: true, code: false }, + InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false, code: false }, ]; for f in &fields { let end = p.len(); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index af1a32ac5..ffeac02ae 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -436,6 +436,14 @@ Editing|Selection|Select Sentence|select.sentence Editing|Selection|Select Paragraph|select.paragraph Editing|Selection|Extend Selection (F8)|select.extend Editing|Selection|Column Selection|select.column +Editing|Fields|Show Field Codes|view.fieldCodes +Editing|Fields|Toggle Field Codes|fields.toggleCode +Editing|Fields|Edit Field Code|fields.setCode +Editing|Fields|Unlink Fields|fields.unlink +Editing|Fields|Lock Fields|fields.lock +Editing|Fields|Unlock Fields|fields.unlock +Editing|Fields|Next Field|fields.next +Editing|Fields|Previous Field|fields.previous Tools|Proofing|AutoCorrect Options|tools.autocorrect Tools|Proofing|Set Proofing Language|review.language Tools|Customize|Customize Ribbon|tools.customizeRibbon diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index fd3162418..dfb7069a4 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -328,7 +328,7 @@ pub fn specs() -> Vec { return Err(CmdError::Params("select a word or give `entry`".into())); } let (_, b) = s.sel.ordered(); - s.doc.insert_object(&b, InlineObject::Field { instr: format!("XE \"{}\"", entry.replace('"', "")), result: String::new(), locked: false }, &CharProps { hidden: Some(true), ..Default::default() })?; + s.doc.insert_object(&b, InlineObject::Field { instr: format!("XE \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, code: false }, &CharProps { hidden: Some(true), ..Default::default() })?; Ok(json!({"entry": entry})) }) .params(r#"{"entry"?: string}"#) @@ -347,7 +347,7 @@ pub fn specs() -> Vec { return Err(CmdError::Params("select a citation or give `entry`".into())); } let (_, b) = s.sel.ordered(); - s.doc.insert_object(&b, InlineObject::Field { instr: format!("TA \\l \"{}\"", entry.replace('"', "")), result: String::new(), locked: false }, &CharProps { hidden: Some(true), ..Default::default() })?; + s.doc.insert_object(&b, InlineObject::Field { instr: format!("TA \\l \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, code: false }, &CharProps { hidden: Some(true), ..Default::default() })?; Ok(json!({"entry": entry})) }) .params(r#"{"entry"?: string}"#) @@ -582,7 +582,7 @@ fn citation(s: &mut Session, v: &Value) -> CmdResult { let props = s.typing_props(); let at = delete_selection(s)?; let instr = if pages.is_empty() { format!("CITATION {tag}") } else { format!("CITATION {tag} \\p {pages}") }; - let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false }, &props)?; + let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false, code: false }, &props)?; s.sel = Selection::caret(end); // A new citation can renumber the others (numbered styles count in order of first citation). if st.numbered() { @@ -699,7 +699,11 @@ fn generated_list(s: &mut Session, instr: &str, title: &str) -> Result<(), CmdEr } let mut head = if title.is_empty() { Paragraph::new() } else { Paragraph::with_text(title, CharProps::default()).styled("Heading1") }; let end = head.len(); - head.insert_object(end, InlineObject::Field { instr: instr.to_string(), result: String::new(), locked: false }, &CharProps::default())?; + head.insert_object( + end, + InlineObject::Field { instr: instr.to_string(), result: String::new(), locked: false, code: false }, + &CharProps::default(), + )?; let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() }; s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?; update_generated(s) @@ -888,7 +892,7 @@ fn cross_ref(s: &mut Session, v: &Value) -> CmdResult { ref_bookmark(s, &pos, show == "number", at)? }; let instr = format!("{} {name} \\h", if show == "page" { "PAGEREF" } else { "REF" }); - let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: result.clone(), locked: false }, &props)?; + let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: result.clone(), locked: false, code: false }, &props)?; s.sel = Selection::caret(end); Ok(json!({"inserted": result})) } diff --git a/crates/engine/src/cmd/fields.rs b/crates/engine/src/cmd/fields.rs new file mode 100644 index 000000000..a78b50151 --- /dev/null +++ b/crates/engine/src/cmd/fields.rs @@ -0,0 +1,356 @@ +//! Working with fields like Word's field keys: show codes instead of results (Alt+F9, Shift+F9), +//! edit a field's code, unlink a field to plain text (Ctrl+Shift+F9), lock and unlock it against +//! updates (Ctrl+F11, Ctrl+Shift+F11) and move from field to field (F11, Shift+F11). +//! +//! Two kinds of field are handled: a simple field ([`InlineObject::Field`], one object whose result +//! is cached text) and a range field ([`InlineObject::FieldStart`] … [`InlineObject::FieldEnd`], +//! whose result is the ordinary content between the markers, e.g. a Zotero citation). Showing the +//! code instead of the result applies to simple fields; range fields keep showing their result. + +use serde_json::{Value, json}; +use wordcraft_doc::para::{InlineObject, OBJ}; +use wordcraft_doc::{Pos, StoryRef}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +/// Longest field code accepted. +const MAX_CODE: usize = 4096; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("view.fieldCodes", "Field Codes", "View › Show", |s, v| { + s.view.field_codes = p::bool(v, "value").unwrap_or(!s.view.field_codes); + s.relayout(); + Ok(json!({"value": s.view.field_codes})) + }) + .key("Alt+F9") + .params(r#"{"value"?: bool}"#) + .pure(), + CommandSpec::new("fields.toggleCode", "Toggle Field Codes", "Insert › Text › Quick Parts", toggle_code).key("Shift+F9"), + CommandSpec::new("fields.setCode", "Edit Field Code", "Insert › Text › Quick Parts", set_code).params(r#"{"code": string}"#), + CommandSpec::new("fields.unlink", "Unlink Fields", "Insert › Text › Quick Parts", unlink).key("Mod+Shift+F9"), + CommandSpec::new("fields.lock", "Lock Fields", "Insert › Text › Quick Parts", |s, _| set_locked(s, true)).key("Mod+F11"), + CommandSpec::new("fields.unlock", "Unlock Fields", "Insert › Text › Quick Parts", |s, _| set_locked(s, false)).key("Mod+Shift+F11"), + CommandSpec::new("fields.next", "Next Field", "Insert › Text › Quick Parts", |s, _| go(s, true)).key("F11").pure(), + CommandSpec::new("fields.previous", "Previous Field", "Insert › Text › Quick Parts", |s, _| go(s, false)).key("Shift+F11").pure(), + CommandSpec::new("fields.selected", "Selected Fields", "Insert › Text › Quick Parts", |s, _| { + Ok(Value::Array(selected(s).iter().map(|f| f.json()).collect())) + }) + .pure(), + ] +} + +/// A field in the document: where its object (or start marker) is, and for a range field where +/// its end marker is. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldAt { + pub at: Pos, + /// The `FieldEnd` of a range field; `None` for a simple field. + pub end: Option, + pub instr: String, + pub locked: bool, + /// Simple fields: shows its code instead of its result (relative to View › Field Codes). + pub code: bool, +} + +impl FieldAt { + fn json(&self) -> Value { + json!({ + "pos": super::pos_json(&self.at), + "instr": self.instr, + "locked": self.locked, + "range": self.end.is_some(), + "code": self.code, + }) + } +} + +/// Every field of a story in document order: simple fields and range fields (by their start). +pub fn story_fields(s: &Session, story: StoryRef) -> Vec { + let mut out = Vec::new(); + for path in s.doc.para_paths(story) { + let Some(para) = s.doc.para(story, &path) else { continue }; + for off in para.object_offsets() { + if let Some(InlineObject::Field { instr, locked, code, .. }) = para.object_at(off) { + out.push(FieldAt { at: Pos::new(story, path.clone(), off), end: None, instr: instr.clone(), locked: *locked, code: *code }); + } + } + } + for r in s.doc.field_ranges(story) { + out.push(FieldAt { at: r.start, end: Some(r.end), instr: r.instr, locked: r.locked, code: false }); + } + out.sort_by(|a, b| a.at.cmp(&b.at)); + out +} + +/// The fields the selection holds: every field starting inside it; with a caret (or a selection +/// holding none), the simple field right after or right before the caret, else the innermost +/// range field around it. +pub fn selected(s: &Session) -> Vec { + let (a, b) = s.sel.ordered(); + let all = story_fields(s, a.story); + if a != b { + let inside: Vec = all.iter().filter(|f| f.at >= a && f.at < b).cloned().collect(); + if !inside.is_empty() { + return inside; + } + } + let width = OBJ.len_utf8(); + let simple = |off: usize| all.iter().find(|f| f.end.is_none() && f.at.path == a.path && f.at.off == off).cloned(); + if let Some(f) = simple(a.off).or_else(|| a.off.checked_sub(width).and_then(simple)) { + return vec![f]; + } + // The innermost range field around the caret: the last-starting one that contains it. + all.into_iter().rfind(|f| f.end.as_ref().is_some_and(|e| f.at < a && a <= *e)).into_iter().collect() +} + +fn none_selected() -> CmdError { + CmdError::Failed("there is no field in the selection".into()) +} + +/// Change the object a field's position names (the simple field or the range field's start). +fn with_field(s: &mut Session, at: &Pos, f: impl FnOnce(&mut InlineObject)) -> Result<(), CmdError> { + let para = s.doc.para_mut(at.story, &at.path)?; + if let Some(o) = para.object_at_mut(at.off) { + f(o); + } + para.touch(); + Ok(()) +} + +/// Shift+F9: the selected simple fields switch between showing their code and their result. +fn toggle_code(s: &mut Session, _: &Value) -> CmdResult { + let fields: Vec = selected(s).into_iter().filter(|f| f.end.is_none()).collect(); + if fields.is_empty() { + return Err(none_selected()); + } + for f in &fields { + with_field(s, &f.at, |o| { + if let InlineObject::Field { code, .. } = o { + *code = !*code; + } + })?; + } + Ok(json!({"fields": fields.len()})) +} + +/// Selected simple fields show their results again (after F9). +pub fn show_results(s: &mut Session) -> Result<(), CmdError> { + for f in selected(s).into_iter().filter(|f| f.code) { + with_field(s, &f.at, |o| { + if let InlineObject::Field { code, .. } = o { + *code = false; + } + })?; + } + Ok(()) +} + +/// Set the selected field's code (the first one, when several are selected), then update fields +/// so its result follows. +fn set_code(s: &mut Session, v: &Value) -> CmdResult { + let code = p::req_str(v, "code")?.trim().to_string(); + if code.is_empty() || code.len() > MAX_CODE || code.contains(OBJ) { + return Err(CmdError::Params("bad field code".into())); + } + let Some(f) = selected(s).into_iter().next() else { return Err(none_selected()) }; + with_field(s, &f.at, |o| match o { + InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } => *instr = code.clone(), + _ => {} + })?; + super::references::update_fields(s)?; + Ok(json!({"instr": code})) +} + +/// Ctrl+F11 / Ctrl+Shift+F11: lock or unlock the selected fields (a locked field keeps its result +/// when fields update). +fn set_locked(s: &mut Session, value: bool) -> CmdResult { + let fields = selected(s); + if fields.is_empty() { + return Err(none_selected()); + } + for f in &fields { + with_field(s, &f.at, |o| match o { + InlineObject::Field { locked, .. } | InlineObject::FieldStart { locked, .. } => *locked = value, + _ => {} + })?; + } + Ok(json!({"fields": fields.len(), "locked": value})) +} + +/// The text a simple field shows now (page numbers from the current layout). +fn shown_text(s: &mut Session, f: &FieldAt) -> String { + let Some(InlineObject::Field { instr, result, .. }) = s.doc.para_at(&f.at).and_then(|p| p.object_at(f.at.off)).cloned() else { + return String::new(); + }; + let l = s.layout(); + let page = l.caret(&f.at).and_then(|c| l.pages.get(c.page)); + let ctx = wordcraft_layout::fields::FieldCtx { + page: page.map(|p| p.number).unwrap_or(1), + pages: u32::try_from(l.pages.len()).unwrap_or(u32::MAX), + section_pages: u32::try_from(l.pages.len()).unwrap_or(u32::MAX), + section: 1, + title: s.doc.core.title.as_str().into(), + author: s.doc.core.creator.as_str().into(), + filename: s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default().into(), + ..Default::default() + }; + wordcraft_layout::fields::field_text(&instr, &result, &ctx).0 +} + +/// Ctrl+Shift+F9: replace the selected fields with what they show (a simple field becomes its +/// result text; a range field loses its markers and keeps its content). +fn unlink(s: &mut Session, _: &Value) -> CmdResult { + let fields = selected(s); + if fields.is_empty() { + return Err(none_selected()); + } + let texts: Vec = fields.iter().map(|f| if f.end.is_none() { shown_text(s, f) } else { String::new() }).collect(); + // Markers to remove and text to put in their place, last first so earlier positions hold. + let mut edits: Vec<(Pos, String)> = Vec::new(); + for (f, t) in fields.iter().zip(texts) { + if let Some(end) = &f.end { + edits.push((end.clone(), String::new())); + } + edits.push((f.at.clone(), t)); + } + edits.sort_by(|a, b| b.0.cmp(&a.0)); + edits.dedup_by(|a, b| a.0 == b.0); + let width = OBJ.len_utf8(); + for (at, text) in &edits { + let para = s.doc.para_mut(at.story, &at.path)?; + if para.object_at(at.off).is_none() { + continue; + } + let props = para.props_of_char(at.off).clone(); + para.delete(at.off, at.off + width)?; + para.insert_text(at.off, text, &props)?; + } + s.clamp_selection(); + Ok(json!({"fields": fields.len()})) +} + +/// F11 / Shift+F11: select the next or previous field in the story (wrapping around). +fn go(s: &mut Session, forward: bool) -> CmdResult { + let (a, _) = s.sel.ordered(); + let all = story_fields(s, a.story); + let target = if forward { + let after = |f: &&FieldAt| if s.sel.is_collapsed() { f.at >= a } else { f.at > a }; + all.iter().find(after).or(all.first()) + } else { + all.iter().rev().find(|f| f.at < a).or(all.last()) + }; + let Some(f) = target else { return Err(CmdError::Failed("the document has no fields".into())) }; + let width = OBJ.len_utf8(); + let end = match &f.end { + Some(e) => Pos { off: e.off + width, ..e.clone() }, + None => Pos { off: f.at.off + width, ..f.at.clone() }, + }; + s.sel = Selection { anchor: f.at.clone(), focus: end }; + sel_result(s) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_doc::para::InlineObject; + + use crate::Session; + + fn session_with_fields() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "Made by "})).unwrap(); + s.run("insert.field", &json!({"instr": "AUTHOR", "result": "Old"})).unwrap(); + s.run("text.insert", &json!({"text": " on "})).unwrap(); + s.run("insert.field", &json!({"instr": "TITLE", "result": "T"})).unwrap(); + s.doc.core.creator = "Ada".into(); + s + } + + fn page_text(s: &mut Session) -> String { + let l = s.layout(); + let mut out = String::new(); + for page in &l.pages { + for it in &page.items { + if let wordcraft_layout::Placed::Lines { para, .. } = it { + for (_, shown, _) in ¶.shown { + out.push_str(shown); + out.push('|'); + } + } + } + } + out + } + + /// Alt+F9 shows every field's code in braces; Shift+F9 flips just the selected one; F9 shows + /// its result again. + #[test] + fn field_codes_show_in_place_of_results() { + let mut s = session_with_fields(); + s.run("caret.docStart", &json!({})).unwrap(); + s.run("fields.next", &json!({})).unwrap(); + assert!(!page_text(&mut s).contains("{ AUTHOR }")); + s.run("view.fieldCodes", &json!({})).unwrap(); + let shown = page_text(&mut s); + assert!(shown.contains("{ AUTHOR }") && shown.contains("{ TITLE }"), "{shown}"); + s.run("view.fieldCodes", &json!({"value": false})).unwrap(); + // Shift+F9 on the selected (first) field only. + s.run("fields.toggleCode", &json!({})).unwrap(); + let shown = page_text(&mut s); + assert!(shown.contains("{ AUTHOR }") && !shown.contains("{ TITLE }"), "{shown}"); + // F9 updates and shows the result again. + s.run("references.updateFields", &json!({})).unwrap(); + let shown = page_text(&mut s); + assert!(!shown.contains("{ AUTHOR }") && shown.contains("Ada"), "{shown}"); + // The toggle is undoable and never saved to files. + s.run("fields.toggleCode", &json!({})).unwrap(); + assert!(serde_json::to_string(&s.doc).unwrap().contains("\"code\":true")); + assert!(s.undo()); + assert!(!page_text(&mut s).contains("{ AUTHOR }")); + } + + /// Editing a code changes the instruction and the result; lock keeps a result through updates; + /// unlink turns the field into its text in one undo step; F11/Shift+F11 walk the fields. + #[test] + fn edit_lock_unlink_and_walk_fields() { + let mut s = session_with_fields(); + s.run("caret.docStart", &json!({})).unwrap(); + s.run("fields.next", &json!({})).unwrap(); + assert_eq!(s.run("fields.selected", &json!({})).unwrap()[0]["instr"], "AUTHOR"); + s.run("fields.next", &json!({})).unwrap(); + assert_eq!(s.run("fields.selected", &json!({})).unwrap()[0]["instr"], "TITLE"); + s.run("fields.next", &json!({})).unwrap(); + assert_eq!(s.run("fields.selected", &json!({})).unwrap()[0]["instr"], "AUTHOR", "wraps around"); + s.run("fields.previous", &json!({})).unwrap(); + assert_eq!(s.run("fields.selected", &json!({})).unwrap()[0]["instr"], "TITLE"); + s.run("fields.previous", &json!({})).unwrap(); + + // Edit the code: the result follows. + s.doc.core.title = "Report".into(); + s.run("fields.setCode", &json!({"code": "TITLE"})).unwrap(); + let objs = |s: &Session| s.doc.body.first().and_then(|b| b.as_para()).map(|p| p.objects.clone()).unwrap_or_default(); + assert!(matches!(objs(&s).first(), Some(InlineObject::Field { instr, result, .. }) if instr == "TITLE" && result == "Report")); + assert!(s.run("fields.setCode", &json!({"code": ""})).is_err()); + + // A locked field keeps its result. + s.run("fields.lock", &json!({})).unwrap(); + s.doc.core.title = "Changed".into(); + s.run("references.updateFields", &json!({})).unwrap(); + assert!(matches!(objs(&s).first(), Some(InlineObject::Field { result, locked: true, .. }) if result == "Report")); + s.run("fields.unlock", &json!({})).unwrap(); + s.run("references.updateFields", &json!({})).unwrap(); + assert!(matches!(objs(&s).first(), Some(InlineObject::Field { result, locked: false, .. }) if result == "Changed")); + + // Unlink: the field becomes its text; Undo brings it back. + s.run("fields.unlink", &json!({})).unwrap(); + assert_eq!(objs(&s).len(), 1); + assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).starts_with("Made by Changed on ")); + assert!(s.undo()); + assert_eq!(objs(&s).len(), 2); + // Nothing selected: an error, not a no-op undo step. + s.run("caret.docStart", &json!({})).unwrap(); + assert!(s.run("fields.lock", &json!({})).is_err()); + } +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index b5671a6f6..35fea5594 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -389,7 +389,11 @@ fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult { } "pageNumber" => { para.props.align = Some(Align::Center); - para.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default())?; + para.insert_object( + 0, + InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }, + &CharProps::default(), + )?; } _ => { para.insert_text(0, "[Type here]", &CharProps::default())?; @@ -416,7 +420,7 @@ fn page_number(s: &mut Session, v: &Value) -> CmdResult { _ => Align::Center, }; let x_of_y = p::str(v, "format").is_some_and(|f| f.contains("of")); - let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }; + let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }; if position == "current" { let props = s.typing_props(); let at = delete_selection(s)?; @@ -435,7 +439,7 @@ fn page_number(s: &mut Session, v: &Value) -> CmdResult { let end = para.len(); para.insert_object( end, - InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false }, + InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, code: false }, &CharProps { bold: Some(true), ..Default::default() }, )?; } else { @@ -451,7 +455,11 @@ fn date_time(s: &mut Session, v: &Value) -> CmdResult { if p::bool(v, "update").unwrap_or(false) { let props = s.typing_props(); let at = delete_selection(s)?; - let end = s.doc.insert_object(&at, InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false }, &props)?; + let end = s.doc.insert_object( + &at, + InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false, code: false }, + &props, + )?; s.sel = Selection::caret(end); } else { type_text(s, &text)?; @@ -554,7 +562,7 @@ fn field(s: &mut Session, v: &Value) -> CmdResult { let result = p::str(v, "result").unwrap_or("").to_string(); let props = s.typing_props(); let at = delete_selection(s)?; - let end = s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false }, &props)?; + let end = s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false, code: false }, &props)?; s.sel = Selection::caret(end); let _ = super::references::update_fields(s); sel_result(s) diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 26a9aa3a8..0142ae9a0 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -150,7 +150,11 @@ fn quote(f: &str) -> String { fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult { let props = s.typing_props(); let at = delete_selection(s)?; - let end = s.doc.insert_object(&at, InlineObject::Field { instr: instr.to_string(), result: placeholder.to_string(), locked: false }, &props)?; + let end = s.doc.insert_object( + &at, + InlineObject::Field { instr: instr.to_string(), result: placeholder.to_string(), locked: false, code: false }, + &props, + )?; s.sel = Selection::caret(end); refresh(s); sel_result(s) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 98bfb9f7d..53d7d3422 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,7 @@ pub mod design; pub mod draw; pub mod edit; pub mod equation; +pub mod fields; pub mod file; pub mod fmt_revisions; pub mod format; @@ -57,6 +58,7 @@ pub fn registry() -> Registry { v.extend(file::specs()); v.extend(design::specs()); v.extend(references::specs()); + v.extend(fields::specs()); v.extend(mailings::specs()); v.extend(citations::specs()); v.extend(objects::specs()); diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 01883637e..ce1500ea7 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -57,8 +57,10 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption) .params(r#"{"label"?: "Figure|Table|Equation", "text"?: string}"#), + // F9: update fields; the selected ones that showed their code show their result again. CommandSpec::new("references.updateFields", "Update Field", "References", |s, _| { update_fields(s)?; + super::fields::show_results(s)?; sel_result(s) }) .key("F9"), @@ -142,7 +144,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult { let mut head = Paragraph::with_text(&title, CharProps::default()).styled("TOCHeading"); head.insert_object( head.len(), - InlineObject::Field { instr: format!("TOC \\o \"1-{levels}\" \\h \\z \\u"), result: String::new(), locked: false }, + InlineObject::Field { instr: format!("TOC \\o \"1-{levels}\" \\h \\z \\u"), result: String::new(), locked: false, code: false }, &CharProps::default(), )?; let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() }; @@ -253,7 +255,7 @@ fn caption(s: &mut Session, v: &Value) -> CmdResult { para.insert_text(0, &base, &CharProps::default())?; para.insert_object( base.len(), - InlineObject::Field { instr: format!("SEQ {label} \\* ARABIC"), result: n.to_string(), locked: false }, + InlineObject::Field { instr: format!("SEQ {label} \\* ARABIC"), result: n.to_string(), locked: false, code: false }, &CharProps::default(), )?; let end = para.len(); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 5190e67e5..d3dc048ac 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -628,7 +628,11 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult { let text = if val.fract() == 0.0 && val.abs() < 1e15 { format!("{}", val as i64) } else { format!("{val:.2}") }; let props = s.typing_props(); let at = s.sel.focus.clone(); - let end = s.doc.insert_object(&at, wordcraft_doc::InlineObject::Field { instr: f.clone(), result: text.clone(), locked: false }, &props)?; + let end = s.doc.insert_object( + &at, + wordcraft_doc::InlineObject::Field { instr: f.clone(), result: text.clone(), locked: false, code: false }, + &props, + )?; s.sel = Selection::caret(end); Ok(json!({"result": text})) } diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 9f4e994f6..fd61067dc 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -195,7 +195,7 @@ pub fn specs() -> Vec { }; let props = s.typing_props(); let at = delete_selection(s)?; - let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: val, locked: false }, &props)?; + let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: val, locked: false, code: false }, &props)?; s.sel = Selection::caret(end); sel_result(s) }) diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index d044ca2d5..c8a2fcf5a 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -120,7 +120,7 @@ pub fn sample_document() -> Document { // Footer: centred page number. let mut f = Paragraph::new().styled("Footer"); f.props.align = Some(Align::Center); - let _ = f.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default()); + let _ = f.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }, &CharProps::default()); let fid = d.add_part(PartKind::Footer, vec![para_block(f)]); d.last_section.footers.default = Some(fid); let mut hd = styled("The Open Studio Handbook\t\tSpring edition", "Header"); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c0e5a5fd8..3dc842751 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -83,6 +83,9 @@ pub struct ViewState { /// Draw tab: the tool dragging on the page uses, and each pen's colour and thickness. #[serde(default)] pub draw: crate::cmd::draw::DrawState, + /// Show field codes instead of their results (`view.fieldCodes`, Alt+F9). + #[serde(default)] + pub field_codes: bool, } fn on() -> bool { @@ -114,6 +117,7 @@ impl Default for ViewState { proofing: true, hide_ink: false, draw: Default::default(), + field_codes: false, } } } @@ -398,6 +402,7 @@ impl Session { show_hidden: self.view.marks, hide_deleted: !self.view.show_markup || self.prefs.markup.hides_deletions(), proofing: self.view.proofing, + field_codes: self.view.field_codes, }; let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing)); @@ -405,7 +410,8 @@ impl Session { } /// A layout for output (PDF, images, print): no proofing marks, print view. pub fn export_layout(&self) -> Arc { - let opts = LayoutOptions { view: ViewMode::Print, web_width: 0.0, show_hidden: false, hide_deleted: false, proofing: false }; + let opts = + LayoutOptions { view: ViewMode::Print, web_width: 0.0, show_hidden: false, hide_deleted: false, proofing: false, field_codes: false }; Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts)) } diff --git a/crates/layout/src/fields.rs b/crates/layout/src/fields.rs index 8a6e4d833..eb79ee7e6 100644 --- a/crates/layout/src/fields.rs +++ b/crates/layout/src/fields.rs @@ -61,6 +61,14 @@ fn format_switch(instr: &str) -> Option { None } +/// The light grey behind a field shown as its code. +pub const FIELD_SHADING: wordcraft_doc::props::Rgb = wordcraft_doc::props::Rgb(0xD9, 0xD9, 0xD9); + +/// What a field shows in code view: its instruction in braces, `{ PAGE }`. +pub fn field_code_text(instr: &str) -> String { + format!("{{ {} }}", instr.trim()) +} + /// Display text for a field and whether it depends on the page it lands on. pub fn field_text(instr: &str, result: &str, ctx: &FieldCtx) -> (String, bool) { let name = field_name(instr); @@ -89,5 +97,7 @@ mod tests { assert_eq!(field_text("NUMPAGES \\* CardText", "", &ctx).0, "Twelve"); assert_eq!(field_text("DATE \\@ \"M/d/yyyy\"", "1/2/2026", &ctx), ("1/2/2026".into(), false)); assert_eq!(field_name("=SUM(ABOVE)"), "SUM(ABOVE)"); + assert_eq!(field_code_text(" PAGE \\* ROMAN "), "{ PAGE \\* ROMAN }"); + assert_eq!(field_code_text(""), "{ }"); } } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 5879a2cbd..f8573189c 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -54,6 +54,9 @@ pub struct LayoutOptions { pub hide_deleted: bool, /// Check spelling and grammar (squiggles). pub proofing: bool, + /// Show field codes (`{ PAGE }`, shaded) instead of field results (View › Field Codes, Alt+F9). + /// A field whose own `code` flag is set shows the other one. + pub field_codes: bool, } /// Something placed on a page (page coordinates, points, y down). @@ -322,7 +325,15 @@ fn hash_of(t: &T) -> u64 { fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default(); - hash_of(&(s, opts.show_hidden, opts.hide_deleted, opts.proofing, wordcraft_proof::user_dictionary().len(), doc.settings.auto_hyphenation)) + hash_of(&( + s, + opts.show_hidden, + opts.hide_deleted, + opts.proofing, + opts.field_codes, + wordcraft_proof::user_dictionary().len(), + doc.settings.auto_hyphenation, + )) } fn has_page_fields(p: &Paragraph) -> bool { @@ -367,6 +378,7 @@ impl Ctx<'_> { label: None, fields: &self.fields, show_hidden: false, + field_codes: false, hide_deleted: false, table: None, proofing: false, @@ -429,6 +441,7 @@ impl Ctx<'_> { label, fields: &self.fields, show_hidden: self.opts.show_hidden, + field_codes: self.opts.field_codes, hide_deleted: self.opts.hide_deleted, table, proofing: self.opts.proofing, @@ -1826,6 +1839,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { label: None, fields: &ctx.fields, show_hidden: ctx.opts.show_hidden, + field_codes: ctx.opts.field_codes, hide_deleted: ctx.opts.hide_deleted, table: None, proofing: false, diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 9aad1bdfb..4834ad500 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -12,7 +12,7 @@ use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara}; use wordcraft_doc::{Document, Paragraph}; use wordcraft_fonts::FaceRef; -use crate::fields::{FieldCtx, field_text}; +use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text}; /// A face at a size with the formatting needed to draw it. #[derive(Clone, Debug)] @@ -278,6 +278,8 @@ pub struct ParaEnv<'a> { pub label: Option<(String, Level)>, pub fields: &'a FieldCtx, pub show_hidden: bool, + /// Show field codes instead of results ([`crate::LayoutOptions::field_codes`]). + pub field_codes: bool, /// Leave tracked deletions out, like hidden text (the final, "No Markup" text). pub hide_deleted: bool, /// The table style's formatting for text in this paragraph's cell (`None` outside tables). @@ -750,10 +752,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { push(&mut b, ClKind::Object(k), 0.0, 0.0); } } - Some(InlineObject::Field { instr, result, .. }) => { - let (t, page_dep) = field_text(instr, result, env.fields); - has_page_fields |= page_dep; - b.shape_atomic(&t, start, end, &rc); + Some(InlineObject::Field { instr, result, code, .. }) => { + if env.field_codes != *code { + // The code in braces on grey, like Word's field shading. + let mut shaded = (*rc).clone(); + if shaded.highlight.is_none() && shaded.shading.is_none() { + shaded.shading = Some(FIELD_SHADING); + } + b.shape_atomic(&field_code_text(instr), start, end, &Arc::new(shaded)); + } else { + let (t, page_dep) = field_text(instr, result, env.fields); + has_page_fields |= page_dep; + b.shape_atomic(&t, start, end, &rc); + } } Some(InlineObject::NoteRef { kind, id, custom }) => { let num = if custom.is_empty() { diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 419ae6c2e..eec5e390d 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -164,6 +164,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f label: None, fields: &fields, show_hidden: false, + field_codes: false, hide_deleted: false, table: text.as_ref(), proofing: false, diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 093b5bea0..f38d4efe9 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -612,7 +612,8 @@ fn table_without_style_stays_borderless() { fn headers_and_page_fields() { let mut d = Document::from_text(&"para\n".repeat(120)); let mut hp = wordcraft_doc::Paragraph::new(); - hp.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false }, &Default::default()).unwrap(); + hp.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false }, &Default::default()) + .unwrap(); let id = d.add_part(wordcraft_doc::PartKind::Footer, vec![wordcraft_doc::para_block(hp)]); d.last_section.footers.default = Some(id); let l = lay(&d); @@ -857,7 +858,11 @@ fn runs_differing_only_in_link_or_decoration_keep_their_own_style() { fn web_view_is_one_page() { let d = Document::from_text(&"text ".repeat(3000)); let mut c = LayoutCache::new(); - let l = layout(&d, &mut c, &LayoutOptions { view: ViewMode::Web, web_width: 800.0, show_hidden: false, hide_deleted: false, proofing: false }); + let l = layout( + &d, + &mut c, + &LayoutOptions { view: ViewMode::Web, web_width: 800.0, show_hidden: false, hide_deleted: false, proofing: false, field_codes: false }, + ); assert_eq!(l.pages.len(), 1); assert!(l.pages[0].h > 800.0); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index ae8d78cf0..3957f1940 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -414,7 +414,7 @@ fn fields_put_their_shown_text_in_the_text_layer() { // characters, are what copying, search and screen readers get. let mut d = Document::new(); let mut f = Paragraph::with_text("Page of ", CharProps::default()); - let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: String::new(), locked: false }; + let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: String::new(), locked: false, code: false }; f.insert_object(5, field("PAGE"), &CharProps::default()).unwrap(); let n = f.len(); f.insert_object(n, field("NUMPAGES"), &CharProps::default()).unwrap(); @@ -538,7 +538,7 @@ fn field_results_are_extractable_text() { let mut p = Paragraph::with_text("See for details.", CharProps::default()); p.insert_object( 4, - InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: "Section 3.01".into(), locked: false }, + InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: "Section 3.01".into(), locked: false, code: false }, &CharProps::default(), ) .unwrap(); @@ -559,7 +559,8 @@ fn field_result_ligatures_extract_all_their_letters() { assert!(wordcraft_fonts::shape(&face, result, &[], |c| c).len() < result.chars().count(), "the result has ligatures"); let mut d = Document::new(); let mut p = Paragraph::with_text("See here.", font.clone()); - p.insert_object(4, InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: result.into(), locked: false }, &font).unwrap(); + p.insert_object(4, InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: result.into(), locked: false, code: false }, &font) + .unwrap(); d.body = vec![para_block(p)]; let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat(); assert!(squash(&text).contains("See the first office here."), "{text:?}"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 151d07c1a..2bdf75cb0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1087,6 +1087,14 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo } return; } + // Double-click a field showing its code: edit the code. + if let Some(pos) = layout.hit(page, x, y, story) + && let Some(field) = field_showing_code(app, &pos) + { + app.session.sel = wordcraft_engine::Selection::caret(field); + let _ = app.run("fields.setCode", json!({})); + return; + } let _ = app.run("select.word", json!({})); return; } @@ -1612,6 +1620,12 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) { item(ui, app, "Paragraph…", "ui.dialog", json!({"name": "paragraph"})); item(ui, app, "Link…", "ui.dialog", json!({"name": "link"})); item(ui, app, "New Comment", "review.newComment", json!({})); + if !wordcraft_engine::cmd::fields::selected(&app.session).is_empty() { + ui.separator(); + item(ui, app, tl!("Update Field"), "references.updateFields", json!({})); + item(ui, app, tl!("Edit Field…"), "fields.setCode", json!({})); + item(ui, app, tl!("Toggle Field Codes"), "fields.toggleCode", json!({})); + } if crate::ribbon::has_picture_selected(&app.session) { ui.separator(); item(ui, app, "Change Picture…", "ui.changePicture", json!({})); @@ -1635,6 +1649,19 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) { } } +/// The simple field at or just before `pos` when it shows its code (View › Field Codes, or its +/// own toggle): where it is. +fn field_showing_code(app: &WordApp, pos: &wordcraft_doc::Pos) -> Option { + let para = app.session.doc.para_at(pos)?; + let width = wordcraft_doc::para::OBJ.len_utf8(); + [Some(pos.off), pos.off.checked_sub(width)].into_iter().flatten().find_map(|off| match para.object_at(off) { + Some(wordcraft_doc::para::InlineObject::Field { code, .. }) if *code != app.session.view.field_codes => { + Some(wordcraft_doc::Pos { off, ..pos.clone() }) + } + _ => None, + }) +} + /// Render options for page rasters shown on screen: on macOS, text is darkened the way the system /// draws it, so a page looks as it does in other Mac apps (exports never are). pub(crate) fn screen_render_options() -> wordcraft_render::RenderOptions { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c57b51351..c0136835a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -188,6 +188,13 @@ pub enum Dialog { fields: Vec, field: String, }, + /// A field's code: a new field (Ctrl+F9, `edit` false) or the selected field's code to edit + /// (double-click a field showing its code, right-click › Edit Field…). + FieldCode { + code: String, + edit: bool, + message: String, + }, /// Mailings › Find Recipient. FindRecipient { text: String, @@ -643,6 +650,8 @@ impl Dialog { Dialog::RecipientList { .. } => "recipientList", Dialog::InsertMergeField { .. } => "insertMergeField", Dialog::FindRecipient { .. } => "findRecipient", + Dialog::FieldCode { edit: false, .. } => "fieldCode", + Dialog::FieldCode { .. } => "editFieldCode", Dialog::MergeRule { rule, .. } if rule == "SKIPIF" => "ruleSkipIf", Dialog::MergeRule { .. } => "ruleIf", Dialog::MatchFields { .. } => "matchFields", @@ -796,6 +805,11 @@ impl Dialog { Dialog::InsertMergeField { field: fields.first().cloned().unwrap_or_default(), fields } } "findRecipient" => Dialog::FindRecipient { text: String::new(), message: String::new() }, + "fieldCode" => Dialog::FieldCode { code: String::new(), edit: false, message: String::new() }, + "editFieldCode" => { + let field = wordcraft_engine::cmd::fields::selected(&app.session).into_iter().next()?; + Dialog::FieldCode { code: field.instr, edit: true, message: String::new() } + } "encryptPassword" => Dialog::EncryptPassword { password: Password::default(), confirm: Password::default(), message: String::new() }, "ruleIf" | "ruleSkipIf" => { let fields = app.session.merge.headers.clone(); @@ -960,6 +974,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::RecipientList { .. } => "Recipient List", Dialog::InsertMergeField { .. } => "Insert Merge Field", Dialog::FindRecipient { .. } => "Find Recipient", + Dialog::FieldCode { edit: false, .. } => "Field", + Dialog::FieldCode { .. } => "Edit Field Code", Dialog::MergeRule { rule, .. } if rule == "SKIPIF" => "Skip Record If", Dialog::MergeRule { .. } => "If…Then…Else", Dialog::MatchFields { .. } => "Match Fields", @@ -1809,6 +1825,30 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } Dialog::DefineList { form } => crate::dialogs_lists::define_list(app, ui, form), Dialog::TrackOptions { form } => crate::dialogs_lists::track_options(app, ui, form), + Dialog::FieldCode { code, edit, message } => { + ui.label(tl!("Field codes:")); + let r = ui.add(egui::TextEdit::singleline(code).desired_width(320.0).font(egui::TextStyle::Monospace)); + if !r.has_focus() && code.is_empty() { + r.request_focus(); + } + ui.label(egui::RichText::new("PAGE · NUMPAGES · DATE \\@ \"MMMM d, yyyy\" · AUTHOR · TITLE · SEQ Figure").small().weak()); + if !message.is_empty() { + ui.label(egui::RichText::new(message.as_str()).small().weak()); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok && !code.trim().is_empty() { + let r = if *edit { + app.run("fields.setCode", json!({"code": code.trim()})) + } else { + app.run("insert.field", json!({"instr": code.trim()})) + }; + match r { + Ok(_) => return true, + Err(e) => *message = e, + } + } + cancel + } Dialog::FindRecipient { text, message } => { ui.horizontal(|ui| { ui.label(tl!("Find:")); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bb3a4da8f..024deb40b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1334,6 +1334,15 @@ Rotate Left 90° Girar 90° a la izquierda Flip Vertical Voltear verticalmente Flip Horizontal Voltear horizontalmente + Toggle Field Codes Alternar códigos de campo + Edit Field Code Editar código de campo + Unlink Fields Desvincular campos + Lock Fields Bloquear campos + Unlock Fields Desbloquear campos + Next Field Campo siguiente + Previous Field Campo anterior + Selected Fields Campos seleccionados + Edit Field… Editar campo… Formatted Con formato Interface size: Tamaño de la interfaz: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte. diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 92a65f45a..f5c78610f 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1323,6 +1323,15 @@ Type the field's name and its switches, for example PAGE \\* roman. Sisesta välja nimi ja lülitid, näiteks PAGE \\* roman. Width and spacing Laius ja vahe from Unicode (hex) Unicode (kuueteistkümnend) + Toggle Field Codes Lülita väljakoodid sisse/välja + Edit Field Code Redigeeri väljakoodi + Unlink Fields Eemalda väljade link + Lock Fields Lukusta väljad + Unlock Fields Ava väljade lukk + Next Field Järgmine väli + Previous Field Eelmine väli + Selected Fields Valitud väljad + Edit Field… Redigeeri välja… Copyright Sign Autoriõiguse märk Insert Date Field Lisa kuupäevaväli Insert Page Field Lisa leheküljeväli diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 15ab5de1f..b188de416 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1318,6 +1318,15 @@ Rotate Left 90° 左へ 90 度回転 Flip Vertical 上下反転 Flip Horizontal 左右反転 + Toggle Field Codes フィールドコードの表示切り替え + Edit Field Code フィールドコードの編集 + Unlink Fields フィールドのリンク解除 + Lock Fields フィールドのロック + Unlock Fields フィールドのロック解除 + Next Field 次のフィールド + Previous Field 前のフィールド + Selected Fields 選択したフィールド + Edit Field… フィールドの編集… Formatted 書式変更 Interface size: 表示サイズ: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index bda7a2594..5e717a154 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1330,6 +1330,15 @@ Rotate Left 90° Girar 90° para a esquerda Flip Vertical Inverter verticalmente Flip Horizontal Inverter horizontalmente + Toggle Field Codes Alternar códigos de campo + Edit Field Code Editar código de campo + Unlink Fields Desvincular campos + Lock Fields Bloquear campos + Unlock Fields Desbloquear campos + Next Field Próximo campo + Previous Field Campo anterior + Selected Fields Campos selecionados + Edit Field… Editar campo… Formatted Formatado Interface size: Tamanho da interface: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado. diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index b9d855732..96030b6e8 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1328,6 +1328,15 @@ Rotate Left 90° Rotiraj ulevo za 90° Flip Vertical Okreni vertikalno Flip Horizontal Okreni horizontalno + Toggle Field Codes Uključi/isključi kodove polja + Edit Field Code Uredi kod polja + Unlink Fields Raskini vezu polja + Lock Fields Zaključaj polja + Unlock Fields Otključaj polja + Next Field Sledeće polje + Previous Field Prethodno polje + Selected Fields Izabrana polja + Edit Field… Uredi polje… Formatted Formatirano Interface size: Veličina interfejsa: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index cf5f3b5ae..ce4882f79 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1332,6 +1332,15 @@ Rotate Left 90° Ротирај улево за 90° Flip Vertical Окрени вертикално Flip Horizontal Окрени хоризонтално + Toggle Field Codes Укључи/искључи кодове поља + Edit Field Code Уреди код поља + Unlink Fields Раскини везу поља + Lock Fields Закључај поља + Unlock Fields Откључај поља + Next Field Следеће поље + Previous Field Претходно поље + Selected Fields Изабрана поља + Edit Field… Уреди поље… Formatted Форматирано Interface size: Величина интерфејса: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9ecf24e30..39a291ee1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1330,6 +1330,15 @@ Rotate Left 90° Повернути ліворуч на 90° Flip Vertical Віддзеркалити по вертикалі Flip Horizontal Віддзеркалити по горизонталі + Toggle Field Codes Перемкнути коди полів + Edit Field Code Змінити код поля + Unlink Fields Від’єднати поля + Lock Fields Заблокувати поля + Unlock Fields Розблокувати поля + Next Field Наступне поле + Previous Field Попереднє поле + Selected Fields Вибрані поля + Edit Field… Змінити поле… Formatted Відформатовано Interface size: Розмір інтерфейсу: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3eb966e38..cc8c45a66 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1329,6 +1329,15 @@ Rotate Left 90° 向左旋转 90° Flip Vertical 垂直翻转 Flip Horizontal 水平翻转 + Toggle Field Codes 切换域代码 + Edit Field Code 编辑域代码 + Unlink Fields 取消域链接 + Lock Fields 锁定域 + Unlock Fields 解除锁定域 + Next Field 下一个域 + Previous Field 上一个域 + Selected Fields 所选域 + Edit Field… 编辑域… Formatted 已设置格式 Interface size: 界面大小: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c47674a02..b0721a948 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1319,6 +1319,15 @@ Rotate Left 90° 向左旋轉 90° Flip Vertical 垂直翻轉 Flip Horizontal 水平翻轉 + Toggle Field Codes 切換功能變數代碼 + Edit Field Code 編輯功能變數代碼 + Unlink Fields 取消功能變數連結 + Lock Fields 鎖定功能變數 + Unlock Fields 解除鎖定功能變數 + Next Field 下一個功能變數 + Previous Field 上一個功能變數 + Selected Fields 選取的功能變數 + Edit Field… 編輯功能變數… Formatted 已設定格式 Interface size: 介面大小: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。 diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index f5a8929e5..b2aafe5c8 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -42,6 +42,7 @@ pub fn key_name(k: Key) -> Option<&'static str> { Key::F7 => "F7", Key::F8 => "F8", Key::F9 => "F9", + Key::F11 => "F11", Key::F12 => "F12", Key::A => "A", Key::B => "B", @@ -284,7 +285,7 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { let events = ctx.input(|i| i.events.clone()); for e in events { if let egui::Event::Key { key, pressed: true, modifiers, .. } = e - && (modifiers.command || matches!(key, Key::F3 | Key::F5 | Key::F7 | Key::F9 | Key::F12)) + && (modifiers.command || matches!(key, Key::F3 | Key::F5 | Key::F7 | Key::F9 | Key::F11 | Key::F12)) { dispatch(app, key, modifiers); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d67717620..e43eca414 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1123,6 +1123,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { // Symbol without a character and Field without a code show their dialogs (#321). "insert.symbol" if !has("char") => Some("symbol"), "insert.field" if !has("instr") => Some("field"), + // Edit Field Code shows the selected field's code (#339). + "fields.setCode" if !has("code") => Some("editFieldCode"), // Table Properties without settings shows the dialog (with settings it applies them). "table.properties" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableProperties"), // Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320). @@ -2536,6 +2538,24 @@ mod tests { assert!(b.dialog.is_none()); } + /// #339: Ctrl+F9 opens the Field dialog (#321) and Edit Field… shows the selected field's code; + /// scripts and agents get an error instead of a dialog. + #[test] + fn field_code_dialogs_open_for_users_only() { + let mut a = app(); + assert_eq!(a.run("insert.field", json!({})).unwrap(), json!({"pending": "field"})); + a.dialog = None; + a.run("insert.field", json!({"instr": "PAGE"})).unwrap(); + a.run("caret.docStart", json!({})).unwrap(); + assert_eq!(a.run("fields.setCode", json!({})).unwrap(), json!({"pending": "editFieldCode"})); + let Some(dialogs::Dialog::FieldCode { code, edit: true, .. }) = &a.dialog else { panic!("{:?}", a.dialog) }; + assert_eq!(code, "PAGE"); + let mut b = app(); + assert!(b.execute("insert.field", json!({})).is_err()); + assert!(b.execute("fields.setCode", json!({})).is_err()); + assert!(b.dialog.is_none()); + } + /// Rules › If…Then…Else… asks for its condition and inserts the IF field it describes; Next /// Record has nothing to ask. Match Fields and Check for Errors show what they found. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 5bf91e592..1a001fe49 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -34,7 +34,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 21 | **Mail merge data sources**: no Excel, Outlook/Contacts, email merge; CSV and typed lists only (#247) | | | Office administrators | 6–10 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | -| 24 | **Customize Ribbon / Keyboard**, Quick Access Toolbar customization; ~125 of Word's ~250 shortcuts (#330) | | [ui-parity.md](ui-parity.md) | Power users | 10–16 | [ui-parity.md](ui-parity.md) | +| 24 | **Customize Ribbon / Keyboard**, Quick Access Toolbar customization; ~130 of Word's ~250 shortcuts (#330; field keys landed, #339) | | [ui-parity.md](ui-parity.md) | Power users | 10–16 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | | 26 | **Minor formats**: Flat OPC XML, Word 2003 XML, MHT, Works/WordPerfect | | | Rare | 20–35 | [file-format-parity.md](file-format-parity.md) | | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) | | 2026-10-10 | trivial | #24: 125 of ~250 keyboard shortcuts after #330 | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 460e23333..95e10e347 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**402 of 434 catalog features have commands (92.6%).** +**410 of 442 catalog features have commands (92.8%).** | Tab | Live | Total | |---|---|---| @@ -23,7 +23,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Picture Format | 14 | 14 | | Shape Format | 3 | 6 | | Header & Footer | 5 | 5 | -| Editing | 24 | 24 | +| Editing | 32 | 32 | | Tools | 3 | 6 | ## Missing diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e858f384d..fd34ab7ff 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -15,10 +15,10 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 | | Quick Access Toolbar | Undo, Redo, Repeat, AutoSave | customizable | 50% | 2–3 | | Customize Ribbon / Customize Keyboard | ❌ | ✅ | 0% | 6–10 | -| Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330). Missing: field-code toggles (Shift+F9, Alt+F9), unlink/lock fields, en dash on the numeric keypad, move/promote paragraph, Go Back | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **35** (`Dialog` enum: Font, 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, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 | +| Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 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). Missing: en dash on the numeric keypad, move/promote paragraph, Go Back | ~250 default shortcuts | ~70% | 2–4 | +| Modal dialogs | **36** (`Dialog` enum: Font, 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, 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) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | -| Context menus (right-click) | text, spelling suggestions; objects and tables partial | rich per context | 55% | 3–5 | +| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — | @@ -42,6 +42,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Dialogs keep their top edge in place when switching tabs (Paragraph, Font, Borders and Shading…): only the bottom edge moves (#507) | | 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) | | 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) | +| 2026-10-11 | trivial | Field codes (#339): View › Field Codes (Alt+F9), Toggle Field Codes (Shift+F9), a field-code popup (double-click, right-click › Edit Field…; Ctrl+F9 opens the Field dialog), unlink/lock/unlock and next/previous field keys; field items in the context menu | | 2026-10-10 | trivial | Tabs dialog (Paragraph › Tabs…, double-click a ruler tab) and Borders and Shading with Borders, Page Border and Shading tabs (#320) | | 2026-10-10 | trivial | Columns (presets, unequal widths, line between, apply to), Symbol (font coverage grid by Unicode subset, recently used, character code, Special Characters) and Field (categories, options, field codes) dialogs (#321) | | 2026-10-10 | trivial | Define New Multilevel List (all nine levels, live preview, `list.define`) and Track Changes Options (show, balloons, insertion/deletion marks and colours, changed-line bars; `review.trackingOptions`, saved per user) (#328) |