mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 08:53:27 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ff7c143b |
@@ -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
@@ -6935,7 +6935,6 @@ dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
"regex-automata",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ¶.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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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), "");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: _,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user