Compare commits

..
Author SHA1 Message Date
FredApps d0ff7c143b fix(wordcraft): satisfy clippy on clipboard.rs after #5441
#5441 left an unused `self` import in the Unix platform module and
placed `picture()` after `mod tests`, which fails clippy -D warnings
on main.
2026-11-11 08:34:50 +01:00
28 changed files with 74 additions and 1479 deletions
-5
View File
@@ -64,11 +64,6 @@ jobs:
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
# apt falls back to mirrors outside ubuntu.com when it doesn't resolve (as on 11 Nov 2026).
- name: Configure apt mirrors
if: runner.os == 'Linux'
uses: storytold/craft-common-actions/actions/configure-apt-mirrors@main
- name: Install Linux build and software-rendering dependencies
if: runner.os == 'Linux'
run: |
Generated
-1
View File
@@ -6935,7 +6935,6 @@ dependencies = [
"log",
"regex",
"regex-automata",
"rustix 1.1.5",
"serde",
"serde_json",
"thiserror 2.0.21",
-5
View File
@@ -51,11 +51,6 @@ chrono = { workspace = true, features = ["clock"] }
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
libc = { workspace = true }
# A plain fsync where a file system refuses the full flush (`sync_file`, safe_save.rs).
# Already in Cargo.lock on Apple targets (craft-atomic), so this adds no new crate.
[target.'cfg(target_vendor = "apple")'.dependencies]
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
encoding_rs = { workspace = true }
+4 -12
View File
@@ -347,24 +347,19 @@ pub(crate) struct InsertEffect {
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, true, None).map(|_| ())
insert_text(s, text, true).map(|_| ())
}
/// [`type_text`] with the effect the insertion had.
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
insert_text(s, text, true, None)
insert_text(s, text, true)
}
/// Insert pasted plain text like [`type_text`], except that its paragraph separators are text
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
/// one would end the list).
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, false, None).map(|_| ())
}
/// Insert typed text in a chosen language without losing caret formatting or tracked changes.
pub fn type_text_with_lang(s: &mut Session, text: &str, language: Option<&str>) -> Result<(), CmdError> {
insert_text(s, text, true, language).map(|_| ())
insert_text(s, text, false).map(|_| ())
}
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
@@ -373,7 +368,7 @@ pub fn type_text_with_lang(s: &mut Session, text: &str, language: Option<&str>)
/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph
/// right-to-left direction when its first strong letter is RTL, and returns whether content or state
/// changed.
fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>) -> Result<InsertEffect, CmdError> {
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
let mut props = s.typing_props();
if s.sel.is_collapsed() {
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
@@ -388,9 +383,6 @@ fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>)
if !enter {
props = props.without_symbol();
}
if let Some(language) = language {
props.lang = Some(language.to_string());
}
let deletion = delete_selection_effect(s)?;
// Typed text is new: never deleted, and not a formatting change of the text around it.
props.fmt_change = None;
+3 -6
View File
@@ -5,7 +5,7 @@ use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor};
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block};
use super::{delete_selection, sel_result, split_para, type_text, type_text_with_lang};
use super::{delete_selection, sel_result, split_para, type_text};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
mod header_footer;
@@ -15,9 +15,7 @@ mod objects;
pub(crate) use header_footer::saved_copy;
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
pub use items::{
DropCapState, default_date_picture, drop_cap_state, format_date, format_date_at, format_date_in, format_date_parts, format_date_parts_in,
};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
use links::{bookmark, delete_bookmark, link, link_targets};
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
@@ -25,7 +23,6 @@ pub use objects::{base64_decode, base64_encode};
use objects::{icon, shape, table, text_box};
pub(crate) use objects::{image_ext, picture};
/// Command specifications for the Insert ribbon tab and menu.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| {
@@ -116,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc.last_section.footers = Default::default();
sel_result(s)
}),
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool, "language"?: "en-US|pt-BR"}"#),
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#),
CommandSpec::new("insert.symbol", "Symbol", "Insert › Symbols", symbol).params(r#"{"char": string, "font"?: family (the symbol's own font)}"#),
CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#),
// Word's Alt+Shift+D / T / P (Control+Shift+… in Word for Mac): DATE, TIME and PAGE fields.
+32 -283
View File
@@ -1,72 +1,8 @@
use super::PartKind;
use super::header_footer::hf_part;
use super::{
Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value,
delete_selection, json, p, para_block, sel_result, type_text, type_text_with_lang,
delete_selection, json, p, para_block, sel_result, type_text,
};
use std::collections::BTreeSet;
fn story_has_page_field(doc: &wordcraft_doc::Document, story: StoryRef, except: Option<usize>) -> bool {
let Some(blocks) = doc.story(story) else { return false };
let mut seen = BTreeSet::from([story]);
let mut pending: Vec<&Block> = blocks.iter().enumerate().filter(|(i, _)| Some(*i) != except).map(|(_, b)| &**b).collect();
while let Some(block) = pending.pop() {
let para = match block {
Block::Para(para) => para,
Block::Table(table) => {
pending.extend(table.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).map(|b| &**b));
continue;
}
};
for object in &para.objects {
if matches!(object, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|name| name.eq_ignore_ascii_case("PAGE")))
{
return true;
}
for id in object.text_boxes() {
if let Some(part) = doc.parts.get(&id).filter(|part| part.kind == PartKind::TextBox)
&& seen.insert(StoryRef::Part(id))
{
pending.extend(part.blocks.iter().map(|b| &**b));
}
}
}
}
false
}
fn page_number_paragraph(style: &str, align: Align, x_of_y: bool) -> Result<Paragraph, CmdError> {
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
let mut para = Paragraph::new().styled(style);
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
}
Ok(para)
}
// Only cached display text is transferable; instructions, flags and formatting remain part of ownership.
fn copy_page_number_results(to: &mut Paragraph, from: &Paragraph) {
for object in &mut to.objects {
let InlineObject::Field { instr, result, .. } = object else { continue };
if let Some(cached) = from.objects.iter().find_map(|object| match object {
InlineObject::Field { instr: source, result, .. } if source.as_str() == instr.as_str() => Some(result),
_ => None,
}) {
result.clone_from(cached);
}
}
}
pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
let position = p::str(v, "position").unwrap_or("bottom");
@@ -86,86 +22,30 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
}
let header = position == "top";
let id = hf_part(s, header);
let story = StoryRef::Part(id);
let style = if header { "Header" } else { "Footer" };
let mut para = page_number_paragraph(style, align, x_of_y)?;
// Replace only a top-level paragraph matching our generated structure. User text,
// field switches/metadata and paragraph/run formatting must not be mistaken for a gallery number.
// Cached field results and paragraph-mark formatting survive gallery changes and identical requests.
if story_has_page_field(&s.doc, story, None) {
let generated = s.doc.story(story).and_then(|blocks| {
blocks.iter().enumerate().find_map(|(i, block)| {
let Block::Para(existing) = &**block else { return None };
let Some(align @ (Align::Left | Align::Center | Align::Right)) = existing.props.align else { return None };
[false, true]
.into_iter()
.any(|x_of_y| {
page_number_paragraph(style, align, x_of_y).is_ok_and(|mut p| {
if p.objects.len() != existing.objects.len() {
return false;
}
p.mark = existing.mark.clone();
copy_page_number_results(&mut p, existing);
*existing == p
})
})
.then_some((i, existing))
})
});
// More than one PAGE makes ownership ambiguous, including in tables or anchored text
// boxes. The traversal visits each story once so cycles cannot hang insertion.
if let Some((i, existing)) = generated
&& !story_has_page_field(&s.doc, story, Some(i))
{
para.mark = existing.mark.clone();
copy_page_number_results(&mut para, existing);
let replacement = para_block(para);
if s.doc.story(story).and_then(|blocks| blocks.get(i)) != Some(&replacement) {
let block = s.doc.story_mut(story)?.get_mut(i).ok_or_else(|| CmdError::Failed("page-number paragraph is missing".into()))?;
*block = replacement;
}
}
return Ok(json!({"story": id}));
let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" });
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
}
let blocks = s.doc.story_mut(story)?;
// A DOCX round trip can add paragraph-mark formatting to the otherwise untouched
// Header/Footer placeholder. Reuse it without losing that formatting or adding a blank line.
if let [block] = blocks.as_slice()
&& let Block::Para(existing) = &**block
{
let mut placeholder = Paragraph::new().styled(style);
placeholder.mark = existing.mark.clone();
if *existing == placeholder {
para.mark = existing.mark.clone();
blocks.clear();
}
}
blocks.push(para_block(para));
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
Ok(json!({"story": id}))
}
/// Insert date and time at the caret or selection, formatted with a Word picture or default format.
pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
let language = p::str(v, "language");
if language.is_some_and(|l| !matches!(l, "en-US" | "pt-BR")) {
return Err(CmdError::Params("language must be en-US or pt-BR".into()));
}
let mut props = s.typing_props();
if let Some(lang) = language {
props.lang = Some(lang.to_string());
}
// Resolve the same run language used by refresh_date_fields on subsequent updates.
// Resolving language from the selection start ordered().0 is intentional for the fixed-text path
// so the typed text takes the character/paragraph styling where the insertion begins.
let effective_language = s.doc.para_at(&s.sel.ordered().0).and_then(|p| s.doc.styles.resolve_char(p.props.style.as_deref(), &props).lang);
// A missing picture follows the selected or inherited language, just like a DATE field
// without a \\@ switch; an explicitly supplied picture is never overridden.
let fmt = p::str(v, "format").unwrap_or_else(|| default_date_picture(effective_language.as_deref()));
if fmt.is_empty() || fmt.len() > 120 || fmt.contains('"') || fmt.contains('\\') {
return Err(CmdError::Params("invalid date picture".into()));
}
let text = format_date_in(fmt, effective_language.as_deref());
let fmt = p::str(v, "format").unwrap_or("M/d/yyyy");
let text = format_date(fmt);
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,
@@ -174,87 +54,32 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
)?;
s.sel = Selection::caret(end);
} else {
type_text_with_lang(s, &text, language)?;
type_text(s, &text)?;
}
sel_result(s)
}
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…),
/// in English.
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
pub fn format_date(fmt: &str) -> String {
format_date_in(fmt, None)
}
/// Format today's date with a Word picture, month and weekday names in the language `lang` (a
/// tag such as `pt-BR`; English when `None` or a language without names here).
pub fn format_date_in(fmt: &str, lang: Option<&str>) -> String {
// Word shows the computer's local date and time (#1707), not UTC's.
let now = crate::cmd::now_unix();
format_date_at_in(fmt, lang, now, crate::cmd::local_offset_secs(now))
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC.
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
format_date_at_in(fmt, None, unix, offset_secs)
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC, with language `lang`.
pub fn format_date_at_in(fmt: &str, lang: Option<&str>, unix: u64, offset_secs: i64) -> String {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
format_date_parts_in(fmt, lang, y, m as usize, d, hms, days)
}
/// Month and weekday names of a language, as Word writes them in date fields: full names, the
/// three-letter forms (`MMM`, `ddd`) being their first three letters, without a full stop.
/// Weekdays start on Thursday (1970-01-01).
struct DateNames {
/// Full month names (January through December).
months: [&'static str; 12],
/// Weekday names starting on Thursday (1970-01-01).
days: [&'static str; 7],
/// The picture of a `DATE` field without `\@`.
default: &'static str,
}
const ENGLISH: DateNames = DateNames {
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
days: ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"],
default: "M/d/yyyy",
};
/// Portuguese (observed in Word for Mac with a pt-BR document): lower-case names, `MMM` = `out`,
/// `ddd` = `qui`, and `dd/MM/yyyy` for a field without a picture.
const PORTUGUESE: DateNames = DateNames {
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
days: ["quinta-feira", "sexta-feira", "sábado", "domingo", "segunda-feira", "terça-feira", "quarta-feira"],
default: "dd/MM/yyyy",
};
/// Choose month and weekday names according to the primary language subtag.
fn date_names(lang: Option<&str>) -> &'static DateNames {
let primary = lang.and_then(|l| l.trim().split(['-', '_']).next()).unwrap_or("");
if primary.eq_ignore_ascii_case("pt") { &PORTUGUESE } else { &ENGLISH }
}
/// The picture Word gives a `DATE` field that has no `\@` switch, in the field's language.
pub fn default_date_picture(lang: Option<&str>) -> &'static str {
date_names(lang).default
format_date_parts(fmt, y, m as usize, d, hms, days)
}
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, hms: (u32, u32, u32), days_since: i64) -> String {
format_date_parts_in(fmt, None, y, m, d, hms, days_since)
}
/// [`format_date_parts`] with month and weekday names in `lang`. Text between single quotes is
/// copied as it is (`'de'`), while doubled apostrophes emit one literal quote anywhere.
#[allow(clippy::too_many_arguments)]
pub fn format_date_parts_in(fmt: &str, lang: Option<&str>, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
let names = date_names(lang);
let month = names.months.get(m.saturating_sub(1)).copied().unwrap_or(names.months[0]);
let day = names.days.get(days_since.rem_euclid(7) as usize).copied().unwrap_or(names.days[0]);
let three = |s: &str| -> String { s.chars().take(3).collect() };
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"];
let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January");
let day = DAYS.get(days_since.rem_euclid(7) as usize).copied().unwrap_or("Monday");
let mut out = String::new();
let chars: Vec<char> = fmt.chars().collect();
let mut i = 0;
@@ -296,11 +121,11 @@ pub fn format_date_parts_in(fmt: &str, lang: Option<&str>, y: i64, m: usize, d:
('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")),
('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)),
('M', 4..) => out.push_str(month),
('M', 3) => out.push_str(&three(month)),
('M', 3) => out.push_str(month.get(..3).unwrap_or(month)),
('M', 2) => out.push_str(&format!("{m:02}")),
('M', 1) => out.push_str(&m.to_string()),
('d' | 'D', 4..) => out.push_str(day),
('d' | 'D', 3) => out.push_str(&three(day)),
('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)),
('d' | 'D', 2) => out.push_str(&format!("{d:02}")),
('d' | 'D', 1) => out.push_str(&d.to_string()),
('H', 2..) => out.push_str(&format!("{hh:02}")),
@@ -512,79 +337,3 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
text_font,
}
}
#[cfg(test)]
mod date_time_locale_tests {
use super::*;
/// A DATE field without an explicit language initially uses the paragraph's and document's
/// inherited pt-BR language, just as the subsequent refresh will.
#[test]
fn date_field_inherits_document_default_locale_on_first_render() {
let mut s = Session::new(wordcraft_doc::Document::new());
std::sync::Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("pt-BR".into());
s.run(
"insert.dateTime",
&json!({
"format": "d 'de' MMMM 'de' yyyy", "update": true
}),
)
.unwrap();
let path = s.doc.para_paths(StoryRef::Body).remove(0);
let p = s.doc.para(StoryRef::Body, &path).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
let expected = format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"));
assert_eq!(*result, expected);
assert!(result.contains(" de "));
let before = result.clone();
crate::cmd::references::refresh_date_fields(&mut s.doc);
let p = s.doc.para(StoryRef::Body, &path).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
assert_eq!(*result, before, "refresh must not switch languages");
}
/// Fixed date text uses the selected pt-BR editing language, without dropping the
/// caret's other character formatting or making the insertion refreshable.
#[test]
fn fixed_date_text_keeps_selected_language() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run(
"insert.dateTime",
&json!({
"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": false
}),
)
.unwrap();
let path = s.doc.para_paths(StoryRef::Body).remove(0);
let p = s.doc.para(StoryRef::Body, &path).unwrap();
assert_eq!(p.props_of_char(0).lang.as_deref(), Some("pt-BR"));
assert_eq!(p.plain_text(), format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
assert!(p.objects.iter().all(|o| !matches!(o, InlineObject::Field { .. })));
}
/// Word's doubled apostrophe escapes one literal quote both before and inside a quoted run.
/// The existing three-character abbreviations must preserve accented Portuguese characters.
#[test]
fn word_date_picture_doubled_apostrophes_and_accented_abbreviations() {
let format = |pic, day| format_date_parts_in(pic, Some("pt-BR"), 2026, 10, day, (12, 0, 0), 2);
assert_eq!(format("d''MM", 20), "20'10");
assert_eq!(format("d 'd''água' MMMM", 20), "20 d'água outubro");
assert_eq!(format("ddd", 20), "sáb");
assert_eq!(format("MMM", 20), "out");
assert_eq!(format("MMMM", 20), "outubro");
}
/// A pt-BR dynamic field keeps its result after DOCX round-trip and refresh.
#[test]
fn pt_br_date_time_dialog_inserts_dynamic_field() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.dateTime", &json!({"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": true})).unwrap();
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let mut doc = wordcraft_docx::read(&bytes).unwrap();
crate::cmd::references::refresh_date_fields(&mut doc);
let paths = doc.para_paths(StoryRef::Body);
let p = doc.para(StoryRef::Body, &paths[0]).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected field") };
assert_eq!(result, &format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
}
}
+4 -16
View File
@@ -378,9 +378,8 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
let name = wordcraft_layout::fields::field_name(instr);
match name.as_str() {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
let lang = field_lang(&s.doc, p, k);
let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref()));
updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref()))));
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic))));
}
"REF" | "PAGEREF" => {
let f = FieldCode::parse(instr);
@@ -506,9 +505,8 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
continue;
}
// Word's default picture when the code carries no `\@` switch.
let lang = field_lang(doc, p, k);
let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref()));
updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref()))));
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic))));
}
if updates.is_empty() {
continue;
@@ -524,16 +522,6 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
}
/// Resolve a field object's language through the paragraph and document defaults.
fn field_lang(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph, k: usize) -> Option<String> {
let off = p.object_offsets().get(k).copied()?;
doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(off)).lang
}
/// Word's default date or time picture when a field instruction carries no `\@` picture switch.
fn default_picture(name: &str, lang: Option<&str>) -> String {
if name == "TIME" { "h:mm am/pm".into() } else { crate::cmd::insert::default_date_picture(lang).into() }
}
/// Most names SET fields give values.
const MAX_SET: usize = 10_000;
+2 -13
View File
@@ -8,14 +8,12 @@ use wordcraft_layout::{NumeralMode, ViewMode};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Toggles or sets a boolean view property, returning the updated state.
fn toggle(s: &mut Session, v: &Value, f: fn(&mut crate::ViewState) -> &mut bool) -> CmdResult {
let cur = *f(&mut s.view);
*f(&mut s.view) = p::bool(v, "value").unwrap_or(!cur);
Ok(json!({"value": *f(&mut s.view)}))
}
/// Returns the command specifications for the View ribbon tab and window management commands.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("view.marks", "Show/Hide ¶", "Home › Paragraph", |s, v| {
@@ -145,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.pure(),
CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", switch_windows)
.params(r#"{"id"?: n (make this open document the active one), "window"?: n (make this open window the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
.params(r#"{"id"?: n (make this open document the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
.pure(),
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
s.push_ui_request(json!({"toggle": "split"}));
@@ -215,8 +213,6 @@ pub fn specs() -> Vec<CommandSpec> {
pub struct Windows {
/// The host can list and move its windows (the desktop app).
pub available: bool,
/// The active window, when known.
pub current: Option<u64>,
/// The other open windows, by id.
pub others: Vec<OtherWindow>,
/// View Side by Side is on, with this window.
@@ -435,7 +431,6 @@ pub(crate) fn restore_zoom(s: &mut Session, z: Zoom) {
s.view.single_column = fit == "onePage" || fit == "pageWidth";
}
/// Sets the view zoom level or fit mode, or requests opening the Zoom dialog.
fn zoom(s: &mut Session, v: &Value) -> CmdResult {
match v.get("value") {
Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => {
@@ -461,13 +456,7 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult {
/// View › Window › Switch Windows: the open documents, or make one of them active.
fn switch_windows(s: &mut Session, v: &Value) -> CmdResult {
if let Some(w) = v.get("window").filter(|x| !x.is_null()) {
let wid = w.as_u64().ok_or_else(|| CmdError::Params("`window` must be an open window's id".into()))?;
if s.windows.available && s.windows.current != Some(wid) && !s.windows.others.iter().any(|other| other.id == wid) {
return Err(CmdError::Params(format!("no open window {wid}")));
}
s.push_ui_request(json!({"windows": "switchWindow", "window": wid}));
} else if let Some(x) = v.get("id").filter(|x| !x.is_null()) {
if let Some(x) = v.get("id").filter(|x| !x.is_null()) {
let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?;
s.activate(id)?;
}
+1 -71
View File
@@ -11,9 +11,6 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// Writes `bytes` safely to `path`, replacing an existing destination atomically via a
/// temporary file. If the destination's folder refuses the temporary file, a new file
/// is created directly and removed on failure; an existing file is left untouched.
pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
let permissions = match fs::metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
@@ -31,41 +28,11 @@ pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
}
// Synchronize the prepared file before replacing the destination. Directory durability
// and network-filesystem/power-loss guarantees are outside this operation's contract.
sync_file(file)
file.sync_all()
});
written.map_err(explain)
}
/// Flushes `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares and virtual file systems
/// refuse it with `ENOTSUP` #6155, `EINVAL`, or `ENOTTY`), this falls back to a plain `fsync`,
/// as Apple's own `removefile` and SQLite do. Plain `fsync` flushes to the drive controller but
/// does not guarantee disk cache durability; any other error is returned as it is.
fn sync_file(file: &File) -> io::Result<()> {
#[cfg(target_vendor = "apple")]
let plain = || rustix::fs::fsync(file).map_err(io::Error::from);
#[cfg(not(target_vendor = "apple"))]
let plain = || Ok(());
or_plain_fsync(file.sync_all(), plain)
}
/// Returns `full`, or calls `fsync()` when `full` failed only because the file system can't do a full flush.
fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
match full {
Err(e) if full_flush_unsupported(&e) => fsync(),
other => other,
}
}
/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB),
/// `EINVAL` (22), or `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`.
fn full_flush_unsupported(e: &io::Error) -> bool {
const EINVAL: i32 = 22;
const ENOTTY: i32 = 25;
const ENOTSUP: i32 = 45;
cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | EINVAL | ENOTTY))
}
/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS
/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003).
/// There is no mode to keep there, so the save goes on; any other failure still stops it.
@@ -481,41 +448,4 @@ mod tests {
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640);
assert_eq!(fs::read(&path).unwrap(), b"Saved again.");
}
/// A share that refuses the full flush (`F_FULLFSYNC`, #6155) still saves through a plain fsync
/// on Apple platforms; every other sync error still fails the save.
#[test]
fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() {
use std::cell::Cell;
let refused = |errno| Err(io::Error::from_raw_os_error(errno));
for (errno, name) in [(45, "ENOTSUP, SMB"), (22, "EINVAL"), (25, "ENOTTY")] {
let fsynced = Cell::new(false);
let r = or_plain_fsync(refused(errno), || {
fsynced.set(true);
Ok(())
});
let apple = cfg!(target_vendor = "apple");
assert_eq!(r.is_ok(), apple, "{name}");
assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms");
}
// The plain fsync's own failure is reported.
if cfg!(target_vendor = "apple") {
let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5)));
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5)));
}
// Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback.
for errno in [5, 28, 13] {
let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}"));
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno)));
}
assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok());
}
#[test]
fn sync_file_flushes_a_local_file() {
let d = Scratch::new();
let path = d.0.join("f.bin");
let file = OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
sync_file(&file).unwrap();
}
}
-14
View File
@@ -214,7 +214,6 @@ fn read_aloud_belongs_to_its_document() {
assert_eq!(s.read_aloud.document, Some(b));
}
/// Tests that `view.switchWindows` lists open documents, activates by document ID, and requests switching by window ID.
#[test]
fn switch_windows_lists_and_activates() {
let mut s = s();
@@ -229,23 +228,10 @@ fn switch_windows_lists_and_activates() {
assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some());
run(&mut s, "view.switchWindows", json!({"id": a}));
assert_eq!(s.document_id(), a);
s.ui_requests.clear();
run(&mut s, "view.switchWindows", json!({"window": 1}));
assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 1})]);
s.windows.available = true;
s.windows.current = Some(1);
s.windows.others = vec![crate::cmd::view::OtherWindow { id: 2, title: "Second".into() }];
s.ui_requests.clear();
run(&mut s, "view.switchWindows", json!({"window": 2}));
assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 2})]);
assert!(s.run("view.switchWindows", &json!({"window": 3})).is_err(), "unknown window key fails when windows are available");
for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] {
assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}");
assert_eq!(s.document_id(), a);
}
for bad in [json!({"window": "1"}), json!({"window": -1}), json!({"window": {}})] {
assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}");
}
let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})];
for id in ["view.switchWindows", "file.closeDocument", "file.new"] {
for j in &junk {
-33
View File
@@ -186,36 +186,3 @@ fn binary_doc_opened_by_extension_refreshes_date_fields() {
assert_eq!(got, expected, "{name}: DATE field stale after opening");
}
}
/// Regression from a synthetic Word document: a visible Brazilian long date is
/// stored in a TIME field and pt-BR is present only in docDefaults, not in field runs.
#[test]
fn a_word_time_field_inherits_pt_br_from_document_defaults() {
const PICTURE: &str = "d' de 'MMMM' de 'yyyy";
const CODE: &str = r#"TIME \@ "d' de 'MMMM' de 'yyyy""#;
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let styles = r#"<?xml version="1.0" encoding="UTF-8"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr><w:lang w:val="pt-BR"/></w:rPr></w:rPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style></w:styles>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Cidade, </w:t></w:r>{}</w:p><w:p><w:r><w:t>Cidade, </w:t></w:r>{}</w:p></w:body></w:document>"#,
field(CODE, "22 de setembro de 2026", false),
field(CODE, "22 de setembro de 2026", false)
);
let source = zip(&[("_rels/.rels", rels), ("word/document.xml", &doc), ("word/_rels/document.xml.rels", doc_rels), ("word/styles.xml", styles)]);
let original = wordcraft_engine::io::open_bytes("pt-br-word.docx", &source).unwrap();
let expected = wordcraft_engine::cmd::insert::format_date_in(PICTURE, Some("pt-BR"));
let assert_correct = |document: &wordcraft_doc::Document| {
let got = fields(document);
assert_eq!(got.len(), 2, "both original fields survive");
for (code, result) in got {
assert_eq!(code, CODE, "field instruction remains editable");
assert_eq!(result, expected, "quoted Portuguese words and month names");
assert!(!result.contains("October") && !result.contains('\''));
assert_eq!(result.matches(" de ").count(), 2);
}
};
assert_correct(&original);
let written = wordcraft_docx::write(&original).unwrap();
let reopened = wordcraft_engine::io::open_bytes("reopened.docx", &written).unwrap();
assert_correct(&reopened);
}
@@ -1,398 +0,0 @@
//! #2201: inserting page numbers must preserve the header/footer story.
use std::sync::Arc;
use serde_json::json;
use wordcraft_doc::para::{GroupChild, InlineObject};
use wordcraft_doc::props::{Align, CharProps};
use wordcraft_doc::{Block, Document, Paragraph, PartKind, Path, StoryRef, Table, para_block};
use wordcraft_engine::{Selection, Session};
fn fields(doc: &Document, story: StoryRef, name: &str) -> usize {
doc.para_paths(story)
.iter()
.filter_map(|path| doc.para(story, path))
.flat_map(|p| &p.objects)
.filter(|o| matches!(o, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|token| token.eq_ignore_ascii_case(name))))
.count()
}
fn field(instr: &str) -> InlineObject {
InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }
}
#[test]
fn numbering_preserves_content_formatting_selection_and_undo_redo() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::from_text("Body text"));
let id = s.run(command, &json!({"text": "Keep this\nSecond line"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
s.doc.para_mut(story, &Path::top(0)).unwrap().props.align = Some(Align::Right);
let mut table = Table::new(1, 2, 300.0);
table.rows[0].cells[0].blocks =
vec![para_block(Paragraph::with_text("Keep table", CharProps { bold: Some(true), ..Default::default() }))];
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
let before = s.doc.clone();
let selection = s.sel.clone();
let blocks = s.doc.story(story).unwrap().clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
assert_eq!(s.sel, selection);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
assert_eq!(s.undo_depth(), depth + 1);
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, after, "repeated numbering must not duplicate or replace content");
let mut back = after.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
let reopened = StoryRef::Part(id);
assert_eq!(back.plain_text(StoryRef::Body), "Body text");
assert!(back.plain_text(reopened).starts_with("Keep this\nSecond line\nKeep table"));
assert_eq!(back.para(reopened, &Path::top(0)).unwrap().props.align, Some(Align::Right));
let Block::Table(table) = &*back.story(reopened).unwrap()[2] else { panic!("lost table") };
let Block::Para(p) = &*table.rows[0].cells[0].blocks[0] else { panic!("lost cell") };
assert_eq!(p.props_at(0).bold, Some(true));
assert_eq!(fields(&back, reopened, "PAGE"), 1);
assert_eq!(fields(&back, reopened, "NUMPAGES"), usize::from(format == "x of y"));
}
}
}
}
#[test]
fn existing_page_field_in_a_table_is_preserved_with_its_switches() {
for depth in [1, 17] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
let mut para = Paragraph::with_text("Existing page: ", CharProps::default());
para.insert_object(para.len(), field(" page \\* MERGEFORMAT "), &CharProps::default()).unwrap();
let mut blocks = vec![para_block(para)];
for _ in 0..depth {
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = blocks;
blocks = vec![Arc::new(Block::Table(table))];
}
s.doc.story_mut(StoryRef::Part(id)).unwrap().extend(blocks);
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
assert!(s.doc == before, "PAGE in a table nested {depth} deep must not be duplicated");
}
}
#[test]
fn existing_range_page_field_is_not_duplicated() {
let mut s = Session::new(Document::new());
let id = s.run("insert.header", &json!({"text": "Keep header"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
para.insert_object(para.len(), InlineObject::FieldStart { instr: "PAGE".into(), locked: true, dirty: true }, &CharProps::default()).unwrap();
para.insert_text(para.len(), "7", &CharProps::default()).unwrap();
para.insert_object(para.len(), InlineObject::FieldEnd, &CharProps::default()).unwrap();
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
assert_eq!(s.doc, before);
}
#[test]
fn unrelated_fields_do_not_prevent_numbering_and_formatted_empty_paragraphs_survive() {
let mut s = Session::new(Document::new());
let id = s.run("insert.header", &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
para.props.align = Some(Align::Right);
let empty = para.clone();
let mut para = Paragraph::new();
para.insert_object(0, field("PAGEREF bookmark"), &CharProps::default()).unwrap();
s.doc.story_mut(story).unwrap().push(para_block(para));
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap(), &empty);
assert_eq!(fields(&s.doc, story, "PAGEREF"), 1);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
}
#[test]
fn fresh_numbering_uses_one_paragraph_and_current_position_stays_inline() {
for position in ["top", "bottom"] {
let mut s = Session::new(Document::new());
let id = s.run("insert.pageNumber", &json!({"position": position})).unwrap()["story"].as_u64().unwrap() as u32;
assert_eq!(s.doc.story(StoryRef::Part(id)).unwrap().len(), 1);
}
let mut s = Session::new(Document::from_text("Before selected after"));
s.run("select.text", &json!({"text": "selected"})).unwrap();
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
assert!(s.doc.parts.is_empty());
assert_eq!(fields(&s.doc, StoryRef::Body, "PAGE"), 1);
assert!(!s.doc.plain_text(StoryRef::Body).contains("selected"));
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
}
#[test]
fn existing_page_field_in_nested_text_boxes_is_preserved() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for levels in [1, 2] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
if levels == 2 {
// Imported stories can contain a nested box, even though the insert command
// does not offer one while the caret is already in a text box.
let inner = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]);
let mut anchor = s.doc.para(story, &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
if let InlineObject::Shape { story, .. } = &mut anchor {
*story = Some(inner);
}
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(inner)));
}
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let before = s.doc.clone();
let selection = s.sel.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y"})).unwrap();
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the text box already contains the page number");
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
let mut back = s.doc.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "NUMPAGES")).sum::<usize>(), 0);
}
}
}
}
#[test]
fn existing_page_field_in_grouped_text_boxes_is_preserved() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let anchor = s.doc.para_mut(story, &Path::top(0)).unwrap().objects.iter_mut().find(|o| o.text_box() == Some(box_id)).unwrap();
*anchor = InlineObject::Group {
w: 144.0,
h: 72.0,
float: Default::default(),
ch_w: 144.0,
ch_h: 72.0,
children: vec![GroupChild { x: 0.0, y: 0.0, obj: anchor.clone() }],
canvas: None,
};
let before = s.doc.clone();
let selection = s.sel.clone();
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the grouped text box already contains the page number");
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
let mut back = s.doc.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
}
}
}
#[test]
fn page_fields_in_unrelated_text_boxes_do_not_prevent_numbering() {
let mut s = Session::new(Document::from_text("Body"));
let box_id = s.run("insert.textBox", &json!({"text": "Body page: "})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
assert_eq!(fields(&s.doc, StoryRef::Part(box_id), "PAGE"), 1);
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
}
#[test]
fn cyclic_text_box_references_do_not_block_numbering() {
let mut s = Session::new(Document::from_text("Body"));
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
let box_id = s.run("insert.textBox", &json!({"text": "A box"})).unwrap()["story"].as_u64().unwrap() as u32;
let anchor = s.doc.para(StoryRef::Part(footer), &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
}
#[test]
fn generated_numbers_switch_format_without_losing_neighboring_content() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Keep table", CharProps::default()))];
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
let blocks = s.doc.story(story).unwrap().clone();
let selection = s.sel.clone();
for (format, align) in [("plain", "left"), ("x of y", "right"), ("plain", "center")] {
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": format, "align": align})).unwrap();
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
assert_eq!(s.sel, selection);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
let expected = match align {
"left" => Align::Left,
"right" => Align::Right,
_ => Align::Center,
};
assert_eq!(s.doc.para(story, &Path::top(blocks.len())).unwrap().props.align, Some(expected));
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
assert!(back.plain_text(StoryRef::Part(id)).starts_with("Keep this\nKeep table"));
assert_eq!(fields(&back, StoryRef::Part(id), "PAGE"), 1);
assert_eq!(fields(&back, StoryRef::Part(id), "NUMPAGES"), 0);
}
}
#[test]
fn repeated_numbering_keeps_clean_state_and_undo_depth() {
for position in ["top", "bottom"] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::new());
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
s.dirty = false;
let doc = s.doc.clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, doc);
assert_eq!(s.undo_depth(), depth, "an identical request must not add an undo step");
assert!(!s.dirty, "an identical request must not dirty the document");
}
}
}
#[test]
fn reopened_empty_header_footer_mark_is_reused_without_a_blank_line() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
s.doc.para_mut(StoryRef::Part(id), &Path::top(0)).unwrap().mark = CharProps { bold: Some(true), italic: Some(true), ..Default::default() };
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
let story = StoryRef::Part(id);
let mark = doc.para(story, &Path::top(0)).unwrap().mark.clone();
assert_eq!(mark.bold, Some(true));
let mut s = Session::new(doc);
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
assert_eq!(s.doc.story(story).unwrap().len(), 1, "reuse the imported empty paragraph rather than add a blank line");
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
let inserted = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y", "align": "right"})).unwrap();
assert_eq!(s.doc.story(story).unwrap().len(), 1);
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().props.align, Some(Align::Right));
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), 1);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, inserted);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
}
}
#[test]
fn user_owned_page_number_lookalikes_are_preserved() {
for kind in ["formatting", "metadata", "another field"] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
match kind {
"formatting" => s.doc.para_mut(story, &Path::top(0)).unwrap().props.indent_left = Some(12.0),
"metadata" => {
let InlineObject::Field { locked, .. } = &mut s.doc.para_mut(story, &Path::top(0)).unwrap().objects[0] else {
panic!("no page field")
};
*locked = true;
}
_ => {
let mut para = Paragraph::with_text("User page: ", CharProps::default());
para.insert_object(para.len(), field("PAGE"), &CharProps::default()).unwrap();
s.doc.story_mut(story).unwrap().push(para_block(para));
}
}
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
assert_eq!(s.doc, before, "preserve {kind} rather than claim the PAGE as generated");
}
}
#[test]
fn reopened_cached_page_numbers_keep_noops_and_gallery_changes() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
for object in &mut s.doc.para_mut(story, &Path::top(1)).unwrap().objects {
if let InlineObject::Field { instr, result, .. } = object {
*result = if instr == "PAGE" { "7" } else { "12" }.into();
}
}
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
let story = StoryRef::Part(id);
let mut s = Session::new(doc);
let cached = |s: &Session, name: &str| {
s.doc.para(story, &Path::top(1)).unwrap().objects.iter().find_map(|object| match object {
InlineObject::Field { instr, result, .. } if instr == name => Some(result.clone()),
_ => None,
})
};
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"), "the fixture must retain the refreshed PAGE cache after reopen");
assert_eq!(cached(&s, "NUMPAGES").as_deref(), (format == "x of y").then_some("12"));
let kept = s.doc.story(story).unwrap()[0].clone();
let mark = s.doc.para(story, &Path::top(1)).unwrap().mark.clone();
let selection = s.sel.clone();
s.dirty = false;
let before = s.doc.clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, before, "identical requests must retain refreshed results");
assert_eq!(s.undo_depth(), depth);
assert!(!s.dirty);
for (next_format, align) in [("x of y", "right"), ("plain", "left")] {
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": next_format, "align": align})).unwrap();
assert_eq!(s.doc.story(story).unwrap()[0], kept);
assert_eq!(s.sel, selection);
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().mark, mark);
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().props.align, Some(if align == "right" { Align::Right } else { Align::Left }));
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(next_format == "x of y"));
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"));
if format == "x of y" && next_format == "x of y" {
assert_eq!(cached(&s, "NUMPAGES").as_deref(), Some("12"));
}
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
}
}
}
+2 -5
View File
@@ -321,9 +321,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
let galley = ui.painter().layout_no_wrap(caption.to_string(), regular(12.5), ui.visuals().text_color());
let (tile, resp) = ui.allocate_exact_size(vec2(galley.size().x.max(150.0), 194.0 + galley.size().y), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), caption));
let flows = crate::rtl::flows(ui);
let r_min_x = if flows { tile.max.x - 150.0 } else { tile.min.x };
let r = Rect::from_min_size(pos2(r_min_x, tile.min.y), vec2(150.0, 194.0));
let r = Rect::from_min_size(tile.min, vec2(150.0, 194.0));
ui.painter().rect(
r,
2.0,
@@ -354,8 +352,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
if let Some(h) = tex {
ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
}
let x = if flows { r.max.x - galley.size().x } else { r.min.x };
ui.painter().galley(pos2(x, r.max.y), galley, ui.visuals().text_color());
ui.painter().galley(pos2(r.min.x, r.max.y), galley, ui.visuals().text_color());
if resp.clicked() {
let _ = app.run("file.new", json!({"template": template}));
app.ui.backstage = false;
-136
View File
@@ -1156,139 +1156,3 @@ fn the_file_page_scrolls_with_the_wheel_at_the_window_edge() {
assert!(after < before - 1.0, "expected doc0.docx to scroll up at the edge: before={before}, after={after}");
assert!((bar.right() - 1432.0).abs() < 1.0, "expected the scrollbar at the window edge: {bar:?}");
}
/// Recursively collects bounding rectangles and text entries from egui shapes.
fn collect_shapes(shape: &egui::Shape, rects: &mut Vec<egui::Rect>, texts: &mut Vec<(String, egui::Pos2, f32)>) {
match shape {
egui::Shape::Rect(r) => rects.push(r.rect),
egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.pos, t.galley.size().x)),
egui::Shape::Vec(v) => {
for s in v {
collect_shapes(s, rects, texts);
}
}
_ => {}
}
}
/// #6151: in an RTL interface (Arabic), template titles on the Start/Home page
/// and New page are right-aligned to their preview thumbnails rather than left-aligned.
#[test]
fn rtl_template_titles_are_right_aligned() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
a.ui.language = "ar".into();
a.ui.backstage = true;
a.ui.backstage_page = "home".into();
let ctx = egui::Context::default();
let mut last_shapes = Vec::new();
for frame in 0..4 {
let input = egui::RawInput {
time: Some(f64::from(frame) * 0.1),
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
a.logic(&ctx.clone());
a.ui(ui);
});
out.textures_delta.clear();
if frame == 3 {
last_shapes = out.shapes;
}
}
let mut rects = Vec::new();
let mut texts = Vec::new();
for s in &last_shapes {
collect_shapes(&s.shape, &mut rects, &mut texts);
}
let blank_caption = crate::i18n::tc("template", "Blank document");
let (_, pos, width) = texts.iter().find(|(t, _, _)| t == blank_caption).expect("blank template caption drawn");
let thumbnail = rects
.iter()
.find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - pos.y).abs() < 1.0)
.expect("blank template thumbnail rect");
let text_right = pos.x + width;
assert!(
(thumbnail.max.x - text_right).abs() < 1.0,
"template caption should be right-aligned with thumbnail in RTL: thumbnail.max.x={}, text_right={}, text_left={}, caption={}",
thumbnail.max.x,
text_right,
pos.x,
blank_caption,
);
// Verify the New page through the normal UI path in Arabic as well (#6151).
a.ui.backstage_page = "new".into();
let mut new_out = ctx.run_ui(
egui::RawInput {
time: Some(0.4),
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))),
..Default::default()
},
|ui| {
a.logic(&ctx.clone());
a.ui(ui);
},
);
new_out.textures_delta.clear();
let mut new_rects = Vec::new();
let mut new_texts = Vec::new();
for s in &new_out.shapes {
collect_shapes(&s.shape, &mut new_rects, &mut new_texts);
}
for (label, _) in super::TEMPLATES {
let caption = crate::i18n::tc("template", label);
if let Some((_, new_pos, new_width)) = new_texts.iter().find(|(t, _, _)| t == caption) {
let right = new_pos.x + new_width;
let has_matching_thumbnail = new_rects.iter().any(|r| {
(r.width() - 150.0).abs() < 1.0
&& (r.height() - 194.0).abs() < 1.0
&& (r.max.y - new_pos.y).abs() < 1.0
&& (r.max.x - right).abs() < 1.0
});
assert!(
has_matching_thumbnail,
"New page: caption '{caption}' must be right-aligned with its 150pt thumbnail (right={right}, y={})",
new_pos.y
);
}
}
// Also verify that a template tile with a wide caption (>150 pt) aligns both the thumbnail
// and caption to the right edge in RTL.
let wide_label = "A very long template title that exceeds 150 points width";
let mut wide_out = ctx.run_ui(
egui::RawInput {
time: Some(0.5),
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))),
..Default::default()
},
|ui| {
crate::rtl::root(ui, |ui| {
super::template_tile(ui, &mut a, wide_label, "sample");
});
},
);
wide_out.textures_delta.clear();
let mut wide_rects = Vec::new();
let mut wide_texts = Vec::new();
for s in &wide_out.shapes {
collect_shapes(&s.shape, &mut wide_rects, &mut wide_texts);
}
let (_, wide_pos, wide_width) = wide_texts.iter().find(|(t, _, _)| t == wide_label).expect("wide template caption drawn");
assert!(*wide_width > 150.0, "wide caption should exceed 150 points: {wide_width}");
let wide_thumbnail = wide_rects
.iter()
.find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - wide_pos.y).abs() < 1.0)
.expect("wide template thumbnail rect");
let wide_text_right = wide_pos.x + wide_width;
assert!(
(wide_thumbnail.max.x - wide_text_right).abs() < 1.0,
"wide template caption and thumbnail should share the same right edge in RTL: thumb.max.x={}, text_right={}",
wide_thumbnail.max.x,
wide_text_right,
);
}
+3 -9
View File
@@ -67,10 +67,6 @@ pub(crate) struct CaptureGeometry {
pub(super) document: u64,
}
/// Explanation returned when a capture is attempted on a secondary viewport.
pub(crate) const UNSUPPORTED_SECONDARY_WINDOW_ERROR: &str =
"capturing a secondary document window is not supported by the viewport renderer; ui.screenshot captures only the window in the root viewport";
impl CaptureGeometry {
/// Returns the document id the capture was admitted for.
pub(crate) fn document(self) -> u64 {
@@ -89,12 +85,10 @@ impl CaptureGeometry {
}
}
/// Explains why the surface cannot be captured: when it is in a secondary viewport that the
/// viewport renderer cannot capture, or when the window is minimized or fully occluded.
/// Explains why the surface cannot be captured, when it is minimized or fully occluded, and
/// returns `None` when it can.
pub(crate) fn unavailable_reason(self) -> Option<&'static str> {
if self.viewport != egui::ViewportId::ROOT {
Some(UNSUPPORTED_SECONDARY_WINDOW_ERROR)
} else if self.surface.minimized {
if self.surface.minimized {
Some("the window is minimized")
} else if self.surface.occluded {
Some("the window is fully occluded")
+1 -14
View File
@@ -1,4 +1,4 @@
use super::geometry::{CaptureGeometry, UNSUPPORTED_SECONDARY_WINDOW_ERROR};
use super::geometry::CaptureGeometry;
use super::{CaptureSlot, Sender, Value, WordApp, json};
/// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless
@@ -128,19 +128,6 @@ pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Opt
reject_shot(reply, token, "screenshot failed: the window geometry is not known yet");
return;
};
if geometry.viewport != egui::ViewportId::ROOT {
let msg = match app.root_window_info() {
Some((root_key, _doc_id, root_title)) if !root_title.is_empty() => format!(
"screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}. Select the root window first (view.switchWindows {{\"window\": {root_key}}}) for {root_title:?}."
),
Some((root_key, ..)) => format!(
"screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}. Select the root window first (view.switchWindows {{\"window\": {root_key}}})."
),
None => format!("screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}."),
};
reject_shot(reply, token, msg);
return;
}
if let Some(reason) = geometry.unavailable_reason() {
reject_shot(reply, token, format!("screenshot failed: {reason}"));
return;
@@ -12,16 +12,11 @@ use crate::options::MeasureExt;
use crate::theme::{Tokens, regular, semibold};
mod columns;
mod date_time;
mod field;
mod symbol;
pub use columns::{ColumnsForm, DropCapForm};
use columns::{columns_ui, drop_cap_ui};
pub use date_time::DateTimeForm;
#[cfg(test)]
pub(crate) use date_time::date_time_preview_matches;
use date_time::date_time_ui;
use field::field_ui;
pub use field::{DATE_FORMATS, FIELD_CATEGORIES, FIELDS, FieldForm, FieldKind, NUMBER_FORMATS, STYLEREF_SWITCHES, field_kind};
use symbol::symbol_ui;
@@ -30,8 +25,6 @@ pub use symbol::{RECENT_SYMBOLS, RecentSymbol, SPECIAL, SUBSETS, SymbolForm, rem
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "form", rename_all = "camelCase")]
pub enum InsertDialog {
/// Date & Time insertion dialog.
DateTime(DateTimeForm),
Columns(ColumnsForm),
Symbol(SymbolForm),
Field(FieldForm),
@@ -42,10 +35,8 @@ pub enum InsertDialog {
}
impl InsertDialog {
/// The dialog name used in command dispatch and UI state.
pub fn name(&self) -> &'static str {
match self {
InsertDialog::DateTime(_) => "dateTime",
InsertDialog::Columns(_) => "columns",
InsertDialog::Symbol(_) => "symbol",
InsertDialog::Field(_) => "field",
@@ -60,7 +51,6 @@ impl InsertDialog {
/// The window title (English; the caller translates it).
pub fn title(&self) -> &'static str {
match self {
InsertDialog::DateTime(_) => "Date and Time",
InsertDialog::Columns(_) => "Columns",
InsertDialog::Symbol(_) => "Symbol",
InsertDialog::Field(_) => "Field",
@@ -78,7 +68,6 @@ impl InsertDialog {
/// `insertSmartArt`, or `dropCap` (Drop Cap Options…, where the paragraph can take one).
pub fn open(name: &str, app: &WordApp) -> Option<InsertDialog> {
Some(match name {
"dateTime" => InsertDialog::DateTime(DateTimeForm::new(app)),
"columns" => InsertDialog::Columns(ColumnsForm::read(app)),
"symbol" => InsertDialog::Symbol(SymbolForm::new(app, 0)),
"specialCharacters" => InsertDialog::Symbol(SymbolForm::new(app, 1)),
@@ -95,7 +84,6 @@ pub fn open(name: &str, app: &WordApp) -> Option<InsertDialog> {
/// Draws the dialog; returns true to close it.
pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut InsertDialog) -> bool {
match d {
InsertDialog::DateTime(f) => date_time_ui(app, ui, f),
InsertDialog::Columns(f) => columns_ui(app, ui, f),
InsertDialog::Symbol(f) => symbol_ui(app, ui, f),
InsertDialog::Field(f) => field_ui(app, ui, f),
@@ -1,141 +0,0 @@
//! Date & Time insertion dialog (Word-style format and language selection).
use egui::{Stroke, Ui};
use serde::Serialize;
use serde_json::{Value, json};
use super::buttons;
use super::field::DATE_FORMATS;
use crate::WordApp;
use crate::theme::Tokens;
/// Predefined Portuguese (Brazil) date and time format pictures.
const PT_DATE_FORMATS: &[&str] = &[
"dd/MM/yyyy",
"dddd, d 'de' MMMM 'de' yyyy",
"d 'de' MMMM 'de' yyyy",
"dd/MM/yy",
"yyyy-MM-dd",
"d-MMM-yy",
"dd.MM.yyyy",
"d MMMM yyyy",
"MMM-yy",
"dd/MM/yyyy HH:mm",
"dd/MM/yyyy HH:mm:ss",
"HH:mm",
"HH:mm:ss",
];
/// The date preview list consists of document values (not interface strings).
/// The i18n audit recognizes these dynamic values instead of treating month names as UI labels.
#[cfg(test)]
pub(crate) fn date_time_preview_matches(value: &str) -> bool {
PT_DATE_FORMATS.iter().chain(DATE_FORMATS.iter()).any(|picture| {
wordcraft_engine::cmd::insert::format_date_in(picture, Some("pt-BR")) == value
|| wordcraft_engine::cmd::insert::format_date_in(picture, Some("en-US")) == value
})
}
/// State of the Word-like Insert > Date & Time dialog, including an optional validation error.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DateTimeForm {
/// Selected Word field picture, not a Rust strftime format.
pub format: String,
/// Language of the resulting text or DATE field.
pub language: String,
/// Whether to insert a refreshable field rather than fixed text.
pub update: bool,
/// Last insertion failure; retain the window so the user can change the choice.
#[serde(skip)]
pub error: Option<String>,
/// Brief status confirmation message (e.g. after setting default preferences).
#[serde(skip)]
pub status: Option<&'static str>,
}
impl DateTimeForm {
/// Restore a saved format/language or choose a sensible default for the UI language.
pub fn new(app: &WordApp) -> Self {
let language = if app.ui.date_time_language.is_empty() {
if crate::i18n::current().code().to_ascii_lowercase().starts_with("pt") { "pt-BR" } else { "en-US" }.to_string()
} else if app.ui.date_time_language.to_ascii_lowercase().starts_with("pt") {
"pt-BR".to_string()
} else {
"en-US".to_string()
};
let list = if language == "pt-BR" { PT_DATE_FORMATS } else { DATE_FORMATS };
let format = if list.contains(&app.ui.date_time_format.as_str()) { app.ui.date_time_format.clone() } else { list[0].to_string() };
Self { format, language, update: app.ui.date_time_update, error: None, status: None }
}
/// Pictures available in the chosen language.
fn formats(&self) -> &'static [&'static str] {
if self.language == "pt-BR" { PT_DATE_FORMATS } else { DATE_FORMATS }
}
/// Engine command parameters corresponding to the current controls.
pub fn params(&self) -> Value {
json!({"format": self.format, "language": self.language, "update": self.update})
}
}
/// Draw the interactive format list, language picker and update checkbox; close on successful
/// insertion or Cancel, keeping errors visible rather than silently dismissing the dialog.
pub(super) fn date_time_ui(app: &mut WordApp, ui: &mut Ui, f: &mut DateTimeForm) -> bool {
let tokens = Tokens::get(ui.ctx());
ui.set_width(520.0);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(350.0);
ui.label(tl!("Date formats:"));
egui::Frame::new().stroke(Stroke::new(1.0, tokens.border)).inner_margin(2.0).show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("date_time_formats").max_height(245.0).auto_shrink([false, false]).show(ui, |ui| {
for picture in f.formats() {
let sample = wordcraft_engine::cmd::insert::format_date_in(picture, Some(&f.language));
if ui.selectable_label(f.format == *picture, sample).on_hover_text(*picture).clicked() {
f.format = (*picture).to_string();
}
}
});
});
});
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(tl!("Language"));
craft_ui::ComboBox::from_id_salt("date_time_lang")
.selected_text(if f.language == "pt-BR" { tl!("Portuguese (Brazil)") } else { tl!("English (United States)") })
.show_ui(ui, |ui| {
if ui.selectable_label(f.language == "pt-BR", tl!("Portuguese (Brazil)")).clicked() {
f.language = "pt-BR".into();
f.format = PT_DATE_FORMATS[0].into();
}
if ui.selectable_label(f.language == "en-US", tl!("English (United States)")).clicked() {
f.language = "en-US".into();
f.format = DATE_FORMATS[0].into();
}
});
});
});
ui.add_space(8.0);
ui.checkbox(&mut f.update, tl!("Update automatically"));
ui.add_space(6.0);
if ui.button(tl!("Set as Default")).clicked() {
app.ui.date_time_format = f.format.clone();
app.ui.date_time_language = f.language.clone();
app.ui.date_time_update = f.update;
f.status = Some(tl!("Default format saved."));
}
if let Some(status) = f.status {
ui.colored_label(tokens.text_dim, status);
}
if let Some(error) = &f.error {
ui.colored_label(ui.visuals().error_fg_color, error);
}
let (ok, cancel) = buttons(ui, "OK", "Cancel", true);
if ok {
match app.run("insert.dateTime", f.params()) {
Ok(_) => return true,
Err(error) => {
f.error = Some(error.to_string());
return false;
}
}
}
cancel
}
@@ -166,155 +166,3 @@ fn field_dialog_lists_the_document_information_fields() {
f.select("SUBJECT");
assert_eq!(f.instruction(), "SUBJECT");
}
/// The Date & Time dialog uses the saved pt-BR editing language when present in UI preferences.
#[test]
fn date_time_dialog_uses_saved_pt_br_language() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.ui.date_time_language = "pt-BR".into();
let InsertDialog::DateTime(mut f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") };
assert_eq!(f.format, "dd/MM/yyyy");
assert_eq!(f.params()["language"], "pt-BR");
assert!(!f.update);
f.update = false;
assert_eq!(f.params()["update"], false);
}
/// Language tags with primary subtag `pt` (such as `pt_BR` or `pt-PT`) are normalized to `pt-BR`.
#[test]
fn date_time_dialog_normalizes_portuguese_language_tags() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.ui.date_time_language = "pt_BR".into();
let InsertDialog::DateTime(f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") };
assert_eq!(f.language, "pt-BR");
assert_eq!(f.format, "dd/MM/yyyy");
a.ui.date_time_language = "pt-PT".into();
let InsertDialog::DateTime(f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") };
assert_eq!(f.language, "pt-BR");
}
/// Click the real egui language menu, date picture, checkbox and OK; ensure the resulting
/// document contains pt-BR fixed text with the language on its run, not just JSON params.
#[test]
fn date_time_dialog_controls_insert_portuguese_fixed_text() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
app.ui.language = "en".into();
app.ui.date_time_language = "en-US".into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap();
h.run_steps(4);
// Go through the actual language combo box and then select a long Portuguese date.
// Scope the click to this dialog: the ribbon has another English-language button.
// The popup choice has a plain accessible label; the main ribbon uses a different control.
h.get_by_role_and_label(Role::Window, "Date and Time").get_by_role(Role::ComboBox).click();
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "Portuguese (Brazil)").click();
h.run_steps(3);
let sample = wordcraft_engine::cmd::insert::format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"));
h.get_by_label(&sample).click();
h.run_steps(2);
// By default, update is unchecked (false). Toggle it on and off to verify the checkbox.
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if !f.update)
));
h.get_by_label("Update automatically").click();
h.run_steps(2);
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.update)
));
h.get_by_label("Update automatically").click();
h.run_steps(2);
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.language == "pt-BR" && f.format == "d 'de' MMMM 'de' yyyy" && !f.update)
));
h.get_by_role_and_label(Role::Button, "OK").click();
h.run_steps(3);
assert!(h.state().dialog.is_none(), "successful insertion closes the dialog");
let doc = &h.state().session.doc;
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body), sample);
let para = doc.para_at(&doc.start_of(wordcraft_doc::StoryRef::Body)).expect("inserted paragraph");
assert_eq!(para.props_of_char(0).lang.as_deref(), Some("pt-BR"));
assert!(para.objects.iter().all(|o| !matches!(o, wordcraft_doc::para::InlineObject::Field { .. })));
}
/// Checking 'Update automatically' inserts a refreshable dynamic DATE field.
#[test]
fn date_time_dialog_controls_insert_dynamic_field() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
app.ui.language = "en".into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap();
h.run_steps(4);
h.get_by_label("Update automatically").click();
h.run_steps(2);
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.update)
));
h.get_by_role_and_label(Role::Button, "OK").click();
h.run_steps(3);
assert!(h.state().dialog.is_none(), "successful insertion closes the dialog");
let doc = &h.state().session.doc;
let para = doc.para_at(&doc.start_of(wordcraft_doc::StoryRef::Body)).expect("inserted paragraph");
assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { .. })));
}
/// An invalid saved picture is reported in the modal, not silently dismissed when OK is clicked.
#[test]
fn date_time_dialog_keeps_open_and_displays_invalid_picture_error() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
app.ui.language = "en".into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap();
if let Some(crate::dialogs::Dialog::Insert(d)) = h.state_mut().dialog.as_mut()
&& let InsertDialog::DateTime(f) = &mut **d
{
f.format = "invalid\\picture".into();
} else {
panic!("date/time dialog expected");
}
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "OK").click();
h.run_steps(3);
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.error.as_deref().is_some_and(|e| e.contains("invalid date picture")))
));
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "");
}
-1
View File
@@ -235,7 +235,6 @@ impl<'a> Exempt<'a> {
|| matches!(plain, "WordCraft" | "WordCraft User" | "Discord" | "https://" | "auto" | "I. II. III." | "i. ii. iii." | "I, II, III, …" | "i, ii, iii, …")
|| plain == "CONFIDENTIAL" // the watermark dialog's text: document content, kept as typed
|| plain == "Figure 1" // the caption dialog's sample: the label goes into the document as is
|| crate::dialogs_insert::date_time_preview_matches(plain)
}
}
+1 -1
View File
@@ -254,7 +254,7 @@ fn controls(tab: &str) -> Vec<Control> {
m("WordArt", "insert.wordArt"),
m("Drop Cap", "insert.dropCap"),
c("Signature Line", "insert.signatureLine"),
d("Date & Time", "dateTime"),
c("Date & Time", "insert.dateTime"),
c("Object", "insert.object"),
c("Equation", "insert.equation"),
m("Symbol", "insert.symbol"),
-10
View File
@@ -294,12 +294,6 @@ pub struct UiState {
pub recent: Vec<String>,
/// Symbols inserted from the Symbol dialog, most recent first (#321).
pub recent_symbols: Vec<dialogs_insert::RecentSymbol>,
/// Saved choices in Insert > Date & Time: format picture.
pub date_time_format: String,
/// Saved language tag in Insert > Date & Time.
pub date_time_language: String,
/// Saved preference for updating inserted date/time fields automatically (defaults to false).
pub date_time_update: bool,
/// Colours picked in the Colors dialog (More Colors…), most recent first (#606).
pub recent_colors: Vec<String>,
/// Fonts applied from the font box, the mini toolbar or the Font dialog, most recent first:
@@ -451,7 +445,6 @@ pub const MAX_RECENT_FONTS: usize = 10;
pub const MAX_FONT_NAME_BYTES: usize = 128;
impl Default for UiState {
/// Default user interface preferences matching Word defaults.
fn default() -> Self {
UiState {
tab: DEFAULT_TAB.into(),
@@ -462,9 +455,6 @@ impl Default for UiState {
quick_access_toolbar: true,
recent: Vec::new(),
recent_symbols: Vec::new(),
date_time_format: String::new(),
date_time_language: String::new(),
date_time_update: false,
recent_colors: Vec::new(),
recent_fonts: Vec::new(),
theme: theme::Appearance::default(),
+1 -2
View File
@@ -44,7 +44,6 @@ pub(super) fn shape_gallery(ui: &mut Ui, app: &mut WordApp) {
});
}
/// Render the Insert ribbon tab controls.
pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Pages", None, app, |ui, app| {
stack(ui, |ui| {
@@ -190,7 +189,7 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
});
stack(ui, |ui| {
small(ui, app, "signature", None, "Signature Line", crate::widgets::ButtonAction::new("insert.signatureLine", json!({}), false));
small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("ui.dialog", json!({"name": "dateTime"}), false));
small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("insert.dateTime", json!({}), false));
small(ui, app, "object", None, "Object", crate::widgets::ButtonAction::new("insert.object", json!({}), false));
});
});
-3
View File
@@ -261,9 +261,6 @@ impl WordApp {
quick_access_toolbar: _,
recent: _,
recent_symbols: _,
date_time_format: _,
date_time_language: _,
date_time_update: _,
recent_colors: _,
recent_fonts: _,
theme: _,
-21
View File
@@ -33,7 +33,6 @@ use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList
use egui::{ViewportCommand, ViewportId};
use serde_json::{Value, json};
use wordcraft_engine::WindowView;
use wordcraft_engine::cmd::view::OtherWindow;
use crate::{WordApp, documents, window_ui::WindowUi};
@@ -307,17 +306,6 @@ impl WordApp {
(viewport, crate::control::SurfaceGeometry::from_viewport(&info, screen_rect, max_texture_side, ctx))
}
/// Returns the window key, document ID and display title of the window currently occupying
/// the root viewport, if one exists and native viewports are in use.
pub(crate) fn root_window_info(&self) -> Option<(u64, u64, String)> {
let viewports = self.windows.viewports.as_ref()?;
let root_id = viewports.root()?;
let root_win = self.windows.open.iter().find(|w| w.id == Some(root_id))?;
let doc_id = root_win.doc;
let title = self.session.documents().iter().find(|d| d.id == doc_id).map(documents::display_title).unwrap_or_default();
Some((root_win.key, doc_id, title))
}
/// The viewport new windows start from: the host's size, icon and app id. Their title and
/// place are the app's.
pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) {
@@ -675,15 +663,6 @@ impl WordApp {
self.settle_windows();
if let Some(&active) = self.windows.recent_keys().first() {
self.enter_window(active);
self.session.windows.current = Some(active);
if self.session.windows.available {
self.session.windows.others = self
.switch_windows_entries()
.into_iter()
.filter(|(k, ..)| *k != active)
.map(|(id, title, _)| OtherWindow { id, title })
.collect();
}
}
}
-6
View File
@@ -71,7 +71,6 @@ pub(crate) fn poll(app: &mut WordApp, _ctx: &egui::Context) {
};
let w = &mut app.session.windows;
w.available = native;
w.current = Some(key);
w.others = others;
w.side_by_side = partner;
w.sync_scroll = sync;
@@ -100,11 +99,6 @@ pub(crate) fn request(app: &mut WordApp, req: &Value) {
let key = app.windows.shown;
let id = |k: &str| req.get(k).and_then(Value::as_u64);
match what {
"switchWindow" => {
if let Some(w) = id("window") {
app.show_window(w);
}
}
"sideBySide" => {
app.canvas.sync_in = 0.0;
if id("off").is_some() {
+14 -93
View File
@@ -386,14 +386,11 @@ fn control_typing_reaches_the_active_window() {
assert_eq!(text(&a), "First", "the other window got none of it");
}
/// `ui.screenshot` captures the window in the root viewport: capturing a secondary document
/// window in a separate native viewport is not supported by the viewport renderer and is refused
/// immediately at admission with an error directing to `view.switchWindows` (#6159). A capture
/// still pending when another window comes forward fails with an error instead of capturing that
/// window. When a window is adopted into the root viewport after the first window closes, it can
/// be captured.
/// `ui.screenshot` captures the active window: the capture is sized from its viewport and the
/// command goes to that viewport. A capture still pending when another window comes forward
/// fails with an error instead of capturing that window.
#[test]
fn screenshot_captures_the_root_window_and_refuses_secondary_windows() {
fn screenshot_captures_the_active_window() {
let ctx = backend();
let mut a = app("First");
frames(&ctx, &mut a, 2);
@@ -406,102 +403,26 @@ fn screenshot_captures_the_root_window_and_refuses_secondary_windows() {
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
let v = replies.try_recv().expect("a secondary window capture is refused at admission");
assert_eq!(v["ok"], false, "{v}");
let err = v["error"].as_str().expect("error message");
assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error explains why: {err}");
assert!(err.contains("view.switchWindows {\"window\": 1}"), "error tells how to activate the root window: {err}");
assert!(a.shots.requests.is_empty(), "refused capture is never queued");
focus(ViewportId::ROOT);
frames(&ctx, &mut a, 2);
assert_eq!(a.active_viewport(), ViewportId::ROOT, "the root window is now active");
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
assert!(replies.try_recv().is_err(), "the capture of the root window is admitted");
assert_eq!(a.shots.requests.len(), 1);
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
assert!(replies.try_recv().is_err(), "the capture is admitted");
assert_eq!(a.shots.requests.iter().map(|s| (s.viewport, s.expected)).collect::<Vec<_>>(), [(child, [800, 600])]);
a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0);
COMMANDS.with(|c| c.borrow_mut().clear());
frames(&ctx, &mut a, 4);
let shot_to =
|id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_)))));
assert!(shot_to(ViewportId::ROOT), "the root window is captured");
assert!(!shot_to(child), "the child window is not");
assert!(shot_to(child), "the active window is captured");
assert!(!shot_to(ViewportId::ROOT), "the first window is not");
let (reply, later) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
focus(child);
frames(&ctx, &mut a, 2);
let v = later.try_recv().expect("the capture was answered");
assert_eq!(v["ok"], false);
assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}");
assert!(a.shots.requests.is_empty());
// When the first window closes, the child window is adopted into the root viewport:
let first_key = a.windows.open.iter().find(|w| w.id == a.windows.viewports.as_ref().unwrap().root()).unwrap().key;
let child_id = a.windows.open.iter().find(|w| w.key != first_key).unwrap().id;
a.close_window(first_key);
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
frames(&ctx, &mut a, 2);
assert_eq!(a.window_count(), 1);
assert_eq!(a.windows.viewports.as_ref().unwrap().root(), child_id);
assert_eq!(a.active_viewport(), ViewportId::ROOT, "the adopted window now occupies the root viewport");
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
assert!(replies.try_recv().is_err(), "the adopted window's capture is admitted");
assert_eq!(a.shots.requests.len(), 1);
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
}
/// #6159: when two windows are open on the same document (`view.newWindow`), `ui.screenshot`
/// while the secondary window is active is refused at admission. Using `view.switchWindows` with
/// `window` selects the root window, allowing a screenshot retry to be admitted and issued.
#[test]
fn screenshot_refuses_secondary_window_on_same_document_and_recovers_via_switch_windows() {
let ctx = backend();
let mut a = app("Shared Doc");
frames(&ctx, &mut a, 2);
a.run("view.newWindow", json!({})).unwrap();
let child = a.active_viewport();
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect));
focus(child);
frames(&ctx, &mut a, 4);
assert_ne!(a.active_viewport(), ViewportId::ROOT);
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
let v = replies.try_recv().expect("secondary window screenshot refused at admission");
assert_eq!(v["ok"], false, "{v}");
let err = v["error"].as_str().expect("error message");
assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error: {err}");
assert!(err.contains("view.switchWindows {\"window\": 1}"), "error recommends root window selection: {err}");
// Recovery path: select the root window via view.switchWindows {"window": 1}
COMMANDS.with(|c| c.borrow_mut().clear());
a.run("view.switchWindows", json!({"window": 1})).unwrap();
assert_eq!(a.active_viewport(), ViewportId::ROOT, "switchWindows selected the root window");
// Simulate the desktop applying the focus request:
focus(ViewportId::ROOT);
frames(&ctx, &mut a, 2);
let root_focused = COMMANDS.with(|c| c.borrow().get(&ViewportId::ROOT).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Focus))));
assert!(root_focused, "switchWindows requested focus on root viewport");
assert_eq!(a.active_viewport(), ViewportId::ROOT, "root window is now active");
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
assert!(replies.try_recv().is_err(), "screenshot admitted on retry");
assert_eq!(a.shots.requests.len(), 1);
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0);
COMMANDS.with(|c| c.borrow_mut().clear());
frames(&ctx, &mut a, 4);
let shot_to =
|id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_)))));
assert!(shot_to(ViewportId::ROOT), "root window screenshot was issued");
assert!(!shot_to(child), "secondary window was not issued");
for r in [replies, later] {
let v = r.try_recv().expect("the capture was answered");
assert_eq!(v["ok"], false);
assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}");
}
assert!(a.shots.requests.is_empty());
}
/// Quitting with two documents of unsaved changes asks about each in its window, the window
+6 -16
View File
@@ -1,6 +1,6 @@
# Control protocol
> **Last updated:** 2026-11-09 · #6159: refuse ui.screenshot on secondary document windows and support root window selection with view.switchWindows.
> **Last updated:** 2026-10-31 · #2596: accept-thread startup failure disables the server and removes its key file; MCP retry policy distinguishes file exports from in-memory images.
Start the app with a control port: `wordcraft --control 7981` (or `WORDCRAFT_CONTROL_PORT=7981`).
A missing or invalid port after `--control` (not 1–65535) prints an error and exits with status 2.
@@ -72,11 +72,9 @@ Unauthenticated connections are limited to 64 while they wait for their first au
The app's native windows (one per document, or several on one document) take part in View ›
Window (#322): `view.arrangeAll` tiles them over the monitor, `view.sideBySide {value?, window?}`
places this window and another one in the left and right halves and turns on Synchronous
Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?, window?}`
lists documents and switches documents or windows. For Side by Side, `window` is an id from
`ui.inspect` → `windows.others` and is optional when only one other window is open. For Switch
Windows, `window` is an open window's key. A secondary-window screenshot refusal can include
the root window key in the suggested `view.switchWindows` command. The
Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?}` lists
and switches documents. `window` is a window id from `ui.inspect` → `windows.others`, the windows
other than the one the command runs in (optional when only one other window is open). The
commands are disabled where there is one window (web, CLI, MCP without the app). Wayland doesn't
let apps move windows, so there Arrange All and Side by Side only resize.
@@ -95,7 +93,7 @@ let apps move windows, so there Arrange All and Side by Side only resize.
| `ui.wheel` | `x`, `y`, `dy`, `dx?`, `unit?` (`point`/`line`/`page`), `phase?` (`start`/`move`/`end`), modifiers | mouse-wheel or trackpad scroll at window coordinates (`dy` < 0 scrolls down) |
| `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) |
| `ui.text` | `text` | typed text through egui |
| `ui.screenshot` | `path?` | PNG of the window in the root viewport: written to `path`, or without one returned as base64 `png` in the reply. Capturing a secondary document window is not supported by the viewport renderer; select the root window with `view.switchWindows {"window": root}` first |
| `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply |
| `ui.render` | `path?`, `page?` (1-based), `scale?`, `doc?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) |
| `ui.parity` | — | feature-catalog parity |
| `ui.resize` | `width`, `height` | asks for that window content size in points; sizes that aren't positive and finite are refused, the rest are clamped to the app's minimum (760 × 480) and to the largest surface the GPU can draw (at most 16384; that limit wins over the minimum), and the reply's `width` and `height` are the size asked for |
@@ -133,7 +131,7 @@ window); in the browser, the one the window shows. Commands work on it unless to
(`"alreadyOpen": true`). Its result carries the document's `id`.
- `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path",
"dirty", "active"}]}`); with `{"id": n}` it makes that document active and brings its window to
the front. With `{"window": key}` it selects that window directly and brings it to the front.
the front.
- `file.closeDocument {"id"?, "force"?}` closes a document; its window closes with it unless it
is the last window.
- `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work
@@ -156,14 +154,6 @@ Input and window methods go to the active window, the one focused last: `ui.clic
`ui.screenshot` (a PNG of that window), `ui.inspect` (its size), `ui.resize` and `ui.focus`. To
work in another window, make its document active first with `view.switchWindows {"id": n}`.
`ui.screenshot` is the exception when multiple document windows are open: the desktop renderer
captures only the window in the root viewport (the first window opened, until that window closes and
another is adopted in its place). While the active window is a secondary document window in a separate
native viewport, `ui.screenshot` returns an error directing the caller to select the root window
instead of hanging for 8 seconds and timing out. Select the root window with
`view.switchWindows {"window": root}` and request the capture again. The error gives the root
window key when it is available.
The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed
in [MCP server](mcp.md#character-and-paragraph-properties).