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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 17:22:25 +02:00 committed by GitHub
parent eb068d3823
commit 39ab3b5433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 673 additions and 55 deletions

View File

@ -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) |

View File

@ -286,13 +286,18 @@ pub enum InlineObject {
children: Vec<GroupChild>,
},
/// 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<usize>]| {
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 { .. })));

View File

@ -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

View File

@ -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),

View File

@ -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);

View File

@ -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");
}

View File

@ -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();

View File

@ -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

View File

@ -328,7 +328,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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}))
}

View File

@ -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<CommandSpec> {
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<Pos>,
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<FieldAt> {
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<FieldAt> {
let (a, b) = s.sel.ordered();
let all = story_fields(s, a.story);
if a != b {
let inside: Vec<FieldAt> = 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<FieldAt> = 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<String> = 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 &para.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());
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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());

View File

@ -57,8 +57,10 @@ pub fn specs() -> Vec<CommandSpec> {
.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();

View File

@ -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}))
}

View File

@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
};
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)
})

View File

@ -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");

View File

@ -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<DocLayout> {
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))
}

View File

@ -61,6 +61,14 @@ fn format_switch(instr: &str) -> Option<NumFormat> {
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(""), "{ }");
}
}

View File

@ -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: Hash>(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,

View File

@ -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() {

View File

@ -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,

View File

@ -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);
}

View File

@ -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:?}");

View File

@ -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<wordcraft_doc::Pos> {
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 {

View File

@ -188,6 +188,13 @@ pub enum Dialog {
fields: Vec<String>,
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:"));

View File

@ -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.

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -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. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -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.

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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.

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно.

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -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. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо.

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -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. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -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);
}

View File

@ -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]

View File

@ -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 |

View File

@ -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

View File

@ -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) |