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:
parent
eb068d3823
commit
39ab3b5433
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 { .. })));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ¶.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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(""), "{ }");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:"));
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue