mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 00:44:27 -06:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6fa29c57 | ||
|
|
ad5e1a65fc | ||
|
|
bf426c1804 | ||
|
|
b67d3b8735 | ||
|
|
e3fef2a9c3 | ||
|
|
060768f1ed | ||
|
|
45db128c15 | ||
|
|
118eb8af1f | ||
|
|
30c1279fd8 | ||
|
|
f936270317 | ||
|
|
0fab74aa38 | ||
|
|
aef086976b | ||
|
|
bce47689ad | ||
|
|
5ad865831f | ||
|
|
0b7cfa86fe | ||
|
|
85eb8bb92e |
Generated
+20
-19
@@ -6806,7 +6806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6844,7 +6844,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -6860,11 +6860,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6878,7 +6878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6895,7 +6895,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6920,7 +6920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6935,6 +6935,7 @@ dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
"regex-automata",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6953,7 +6954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6967,7 +6968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6994,7 +6995,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -7002,7 +7003,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -7022,7 +7023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -7034,7 +7035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7042,7 +7043,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7063,7 +7064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7077,7 +7078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
@@ -7110,7 +7111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -7131,7 +7132,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7294,7 +7295,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.1"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
@@ -164,7 +164,7 @@ mod platform {
|
||||
use std::io::Read;
|
||||
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
|
||||
fn wayland() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
@@ -348,6 +348,22 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
|
||||
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
|
||||
/// (or isn't reachable): arboard is asked then, which also covers X11.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
|
||||
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
|
||||
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -369,22 +385,6 @@ mod platform {
|
||||
assert!(complete(0, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
|
||||
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
|
||||
/// (or isn't reachable): arboard is asked then, which also covers X11.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
|
||||
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
|
||||
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -348,7 +348,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -436,6 +436,36 @@ fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, m
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a
|
||||
/// non-empty header story ends with a paragraph mark that only separates it from the next story
|
||||
/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the
|
||||
/// guard would add an empty paragraph to every header and footer and push the body down when
|
||||
/// the header reaches past the top margin (#5922).
|
||||
///
|
||||
/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a
|
||||
/// mark stays a part (an empty header, not one linked to the previous section), and a mark right
|
||||
/// after a table row stays the paragraph that closes the table.
|
||||
fn header_story_blocks(
|
||||
ctx: &WalkCtx<'_>,
|
||||
a: u32,
|
||||
b: u32,
|
||||
pics: &mut media::Pictures,
|
||||
max_paragraphs: usize,
|
||||
) -> Result<wordcraft_doc::Blocks, DocbinError> {
|
||||
let mut paras = walk(ctx, a, b, pics);
|
||||
if ctx.paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark);
|
||||
if let [.., prev, last] = paras.as_slice() {
|
||||
let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none();
|
||||
if guard && outside_table(prev) {
|
||||
paras.pop();
|
||||
}
|
||||
}
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
|
||||
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
|
||||
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
|
||||
|
||||
@@ -1157,6 +1157,58 @@ fn header_story_becomes_part() {
|
||||
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text"))));
|
||||
}
|
||||
|
||||
/// The odd header's blocks for a one-section document whose header story is `story`, with
|
||||
/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`):
|
||||
/// paragraph texts, a table as `<table>`; `None` when the slot has no part.
|
||||
fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec<u8>)>) -> Option<Vec<String>> {
|
||||
let spec = StructSpec {
|
||||
text: "body\r",
|
||||
header_stories: vec![story],
|
||||
sections: vec![(5, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries,
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let id = doc.last_section.headers.default?;
|
||||
let part = doc.parts.get(&id).expect("part");
|
||||
Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "<table>".to_string(), |p| p.text.clone())).collect())
|
||||
}
|
||||
|
||||
/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that
|
||||
/// is not part of its contents. Read as a paragraph, it put an empty line under every header
|
||||
/// and pushed the first body line down when the header reached past the top margin.
|
||||
#[test]
|
||||
fn header_story_guard_mark_is_not_a_paragraph() {
|
||||
let plain = || vec![(0, 0, Vec::new())];
|
||||
// One line typed into the header: content "Header\r" + guard.
|
||||
assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()]));
|
||||
// Enter pressed after it: the empty paragraph is content and stays, only the guard goes.
|
||||
assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()]));
|
||||
// An empty header (one empty content paragraph + guard) is still a header of its own.
|
||||
assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()]));
|
||||
// A story that is only a mark (no guard) keeps its paragraph and its part.
|
||||
assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()]));
|
||||
// A story without a guard keeps its last paragraph.
|
||||
assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()]));
|
||||
}
|
||||
|
||||
/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that
|
||||
/// closes the table, not a guard to drop.
|
||||
#[test]
|
||||
fn header_story_mark_after_a_table_row_is_kept() {
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01])]);
|
||||
let ttp: Vec<u8> = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat();
|
||||
// Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8).
|
||||
let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())];
|
||||
assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["<table>".to_string(), String::new()]));
|
||||
}
|
||||
|
||||
/// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column,
|
||||
/// built per the spec (cb counts the bytes after it, plus one).
|
||||
fn t_def_table(edges: &[i16]) -> Vec<u8> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wordcraft_doc::math::MathJc;
|
||||
|
||||
use super::{
|
||||
CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx,
|
||||
TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink,
|
||||
@@ -13,6 +15,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an inline child element (`w:r`, `m:oMath`, `m:oMathPara`, `w:ins`, etc.).
|
||||
fn read_inline(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
|
||||
match k.name.as_str() {
|
||||
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
|
||||
@@ -76,25 +79,38 @@ impl Reader<'_> {
|
||||
}
|
||||
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
|
||||
"m:oMath" => {
|
||||
let mut props = self.run_props_none(ctx);
|
||||
let mut math = crate::read::math::read_omath(k, Default::default());
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_omath(sc, pb, k, Default::default(), false, &props);
|
||||
}
|
||||
"m:oMathPara" => {
|
||||
let props = self.run_props_none(ctx);
|
||||
let base_props = self.run_props_none(ctx);
|
||||
let jc = crate::read::math::para_jc(k);
|
||||
for m in k.children("m:oMath") {
|
||||
let mut math = crate::read::math::read_omath(m, jc);
|
||||
let mut props = props.clone();
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
for c in k.els() {
|
||||
match c.name.as_str() {
|
||||
"m:oMath" => self.emit_omath(sc, pb, c, jc, true, &base_props),
|
||||
"w:ins" | "w:moveTo" => {
|
||||
let mut sub_ctx = ctx.clone();
|
||||
sub_ctx.ins =
|
||||
Some(if c.name == "w:moveTo" { self.move_revision(true, c, false) } else { self.revision(RevisionKind::Insert, c) });
|
||||
let sub_props = self.run_props_none(&sub_ctx);
|
||||
for m in c.children("m:oMath") {
|
||||
self.emit_omath(sc, pb, m, jc, true, &sub_props);
|
||||
}
|
||||
}
|
||||
"w:del" | "w:moveFrom" => {
|
||||
let mut sub_ctx = ctx.clone();
|
||||
sub_ctx.del = Some(if c.name == "w:moveFrom" {
|
||||
self.move_revision(false, c, false)
|
||||
} else {
|
||||
self.revision(RevisionKind::Delete, c)
|
||||
});
|
||||
let sub_props = self.run_props_none(&sub_ctx);
|
||||
for m in c.children("m:oMath") {
|
||||
self.emit_omath(sc, pb, m, jc, true, &sub_props);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
@@ -384,6 +400,17 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an `m:oMath` element and emit its [`InlineObject::Equation`].
|
||||
fn emit_omath(&mut self, sc: &mut StoryCtx, pb: &mut PB, m: &El, jc: MathJc, display: bool, props: &CharProps) {
|
||||
let mut math = crate::read::math::read_omath(m, jc);
|
||||
let mut props = props.clone();
|
||||
if let Some(color) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(color));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display, math }, &props);
|
||||
}
|
||||
|
||||
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
|
||||
match sink(sc) {
|
||||
Some(f) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::section::comment_reference_style;
|
||||
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
|
||||
|
||||
impl Writer<'_> {
|
||||
/// Write an inline object (`Bookmark`, `Comment`, `Equation`, `Field`, `Shape`, etc.).
|
||||
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
|
||||
match o {
|
||||
// Written by `para_content` (inline) or `blocks` (around paragraphs).
|
||||
@@ -51,10 +52,12 @@ impl Writer<'_> {
|
||||
Some(TextColor::Rgb(c)) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
self.rev_open(w, props);
|
||||
match crate::write::math::resolve_numbers(math, *display, &mut self.eq_number) {
|
||||
Some(m) => crate::write::math::write_equation(w, linear, *display, &m, color),
|
||||
None => crate::write::math::write_equation(w, linear, *display, math, color),
|
||||
}
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
|
||||
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Line numbering (`w:lnNumType`, ECMA-376 Part 1 §17.6.8) and the built-in "line number"
|
||||
//! character style that formats the numbers (#6018).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering};
|
||||
use wordcraft_doc::{CharProps, Document, Rgb, Style, StyleKind, TextColor};
|
||||
|
||||
/// Every line-numbering option and a localised "line number" style (German Word calls its id
|
||||
/// `Zeilennummer`) survive a save and reopen, beside two columns.
|
||||
#[test]
|
||||
fn line_numbering_options_and_style_round_trip() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
let numbering = LineNumbering { count_by: 5, start: 3, distance: 21.6, restart: LineNumberRestart::Continuous };
|
||||
d.last_section.line_numbers = Some(numbering.clone());
|
||||
d.last_section.columns = Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
|
||||
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "Zeilennummer".into(),
|
||||
name: "Line Number".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: CharProps { size: Some(8.0), color: Some(red), italic: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.last_section.line_numbers, Some(numbering));
|
||||
assert_eq!(back.last_section.columns.count, 2);
|
||||
let st = back.styles.find("line number").expect("the line number style");
|
||||
assert_eq!(st.id, "Zeilennummer");
|
||||
assert_eq!((st.chr.size, st.chr.color, st.chr.italic), (Some(8.0), Some(red), Some(true)));
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod glossary;
|
||||
mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod line_numbering;
|
||||
mod malformed;
|
||||
mod missing_pictures;
|
||||
mod nested_block_controls;
|
||||
|
||||
@@ -994,6 +994,80 @@ fn tracked_changes_round_trip() {
|
||||
assert!(r.settings.track_changes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_equations_round_trip() {
|
||||
// Issue #6011: equations saved with tracked revision marks (w:ins / w:del) around math.
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
d.settings.track_changes = true;
|
||||
let ins = CharProps { ins: Some(0), ..Default::default() };
|
||||
let del = CharProps { del: Some(1), ..Default::default() };
|
||||
let plain = CharProps::default();
|
||||
let mut p = Paragraph::with_text("Formula: ", CharProps::default());
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "x+1".into(), display: false, math: Default::default() }, &ins).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "E=mc^2".into(), display: true, math: Default::default() }, &del).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "a^2+b^2=c^2".into(), display: true, math: Default::default() }, &ins).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "y=mx+b".into(), display: false, math: Default::default() }, &del).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "z=1".into(), display: false, math: Default::default() }, &plain).unwrap();
|
||||
d.body = vec![para_block(p.clone())];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(xml.contains(r#"<w:ins w:id="1" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMath>"#), "ins directly encloses inline math: {xml}");
|
||||
assert!(
|
||||
xml.contains(r#"<w:del w:id="2" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMathPara>"#),
|
||||
"del directly encloses display math: {xml}"
|
||||
);
|
||||
assert!(
|
||||
xml.contains(r#"<w:ins w:id="3" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMathPara>"#),
|
||||
"ins directly encloses display math: {xml}"
|
||||
);
|
||||
assert!(xml.contains(r#"<w:del w:id="4" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMath>"#), "del directly encloses inline math: {xml}");
|
||||
assert!(xml.contains(r#"</w:del><m:oMath>"#), "plain equation follows closing tag without revision wrapper: {xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
assert_eq!(r.revisions, d.revisions);
|
||||
let got = ¶s(&r)[0];
|
||||
assert_eq!(got.objects.len(), 5);
|
||||
let eq_summary: Vec<(bool, String)> = got
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Equation { display, linear, .. } => (*display, linear.clone()),
|
||||
_ => panic!("expected equation object, got {o:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
eq_summary,
|
||||
vec![(false, "x+1".into()), (true, "E=mc^2".into()), (true, "a^2+b^2=c^2".into()), (false, "y=mx+b".into()), (false, "z=1".into()),]
|
||||
);
|
||||
assert_eq!(got.runs, p.runs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_omath_para_revisions_read() {
|
||||
// Defensive reader check: w:ins and w:del nested inside m:oMathPara.
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Charles".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
let doc_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMathPara><w:ins w:id="1" w:author="Ada" w:date="2026-05-01T10:00:00Z"><m:oMath><m:r><m:t>x+1</m:t></m:r></m:oMath></w:ins><w:del w:id="2" w:author="Charles" w:date="2026-05-02T10:00:00Z"><m:oMath><m:r><m:t>y-1</m:t></m:r></m:oMath></w:del></m:oMathPara></w:p></w:body></w:document>"#;
|
||||
let base = wordcraft_docx::write(&d).unwrap();
|
||||
let bytes = replace_part(&base, "word/document.xml", doc_xml.as_bytes());
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
let got = ¶s(&r)[0];
|
||||
assert_eq!(got.objects.len(), 2);
|
||||
assert!(matches!(&got.objects[0], InlineObject::Equation { display: true, linear, .. } if linear == "x+1"));
|
||||
assert!(matches!(&got.objects[1], InlineObject::Equation { display: true, linear, .. } if linear == "y-1"));
|
||||
assert_eq!(got.runs[0].props.ins, Some(0));
|
||||
assert_eq!(got.runs[1].props.del, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_paragraph_marks_round_trip() {
|
||||
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
|
||||
|
||||
@@ -51,6 +51,11 @@ 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,19 +347,24 @@ 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).map(|_| ())
|
||||
insert_text(s, text, true, None).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)
|
||||
insert_text(s, text, true, None)
|
||||
}
|
||||
|
||||
/// 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).map(|_| ())
|
||||
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(|_| ())
|
||||
}
|
||||
|
||||
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
|
||||
@@ -368,7 +373,7 @@ pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
/// 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) -> Result<InsertEffect, CmdError> {
|
||||
fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>) -> 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));
|
||||
@@ -383,6 +388,9 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect,
|
||||
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};
|
||||
use super::{delete_selection, sel_result, split_para, type_text, type_text_with_lang};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod header_footer;
|
||||
@@ -15,7 +15,9 @@ 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, drop_cap_state, format_date, format_date_at, format_date_parts};
|
||||
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,
|
||||
};
|
||||
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};
|
||||
@@ -23,6 +25,7 @@ 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, _| {
|
||||
@@ -113,7 +116,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}"#),
|
||||
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.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,8 +1,72 @@
|
||||
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,
|
||||
delete_selection, json, p, para_block, sel_result, type_text, type_text_with_lang,
|
||||
};
|
||||
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");
|
||||
@@ -22,30 +86,86 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let header = position == "top";
|
||||
let id = hf_part(s, header);
|
||||
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 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}));
|
||||
}
|
||||
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
|
||||
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));
|
||||
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 fmt = p::str(v, "format").unwrap_or("M/d/yyyy");
|
||||
let text = format_date(fmt);
|
||||
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());
|
||||
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,
|
||||
@@ -54,32 +174,87 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
} else {
|
||||
type_text(s, &text)?;
|
||||
type_text_with_lang(s, &text, language)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
|
||||
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…),
|
||||
/// in English.
|
||||
pub fn format_date(fmt: &str) -> String {
|
||||
// Word shows the computer's local date and time (#1707), not UTC's.
|
||||
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 {
|
||||
let now = crate::cmd::now_unix();
|
||||
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
|
||||
format_date_at_in(fmt, lang, 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(fmt, y, m as usize, d, hms, days)
|
||||
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 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, (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");
|
||||
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() };
|
||||
let mut out = String::new();
|
||||
let chars: Vec<char> = fmt.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -121,11 +296,11 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
|
||||
('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(month.get(..3).unwrap_or(month)),
|
||||
('M', 3) => out.push_str(&three(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(day.get(..3).unwrap_or(day)),
|
||||
('d' | 'D', 3) => out.push_str(&three(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}")),
|
||||
@@ -337,3 +512,79 @@ 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,8 +378,9 @@ 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 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))));
|
||||
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()))));
|
||||
}
|
||||
"REF" | "PAGEREF" => {
|
||||
let f = FieldCode::parse(instr);
|
||||
@@ -505,8 +506,9 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
|
||||
continue;
|
||||
}
|
||||
// Word's default picture when the code carries no `\@` switch.
|
||||
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))));
|
||||
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()))));
|
||||
}
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
@@ -522,6 +524,16 @@ 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,12 +8,14 @@ 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| {
|
||||
@@ -143,7 +145,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)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
|
||||
.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"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
|
||||
s.push_ui_request(json!({"toggle": "split"}));
|
||||
@@ -213,6 +215,8 @@ 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.
|
||||
@@ -431,6 +435,7 @@ 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()) => {
|
||||
@@ -456,7 +461,13 @@ 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(x) = v.get("id").filter(|x| !x.is_null()) {
|
||||
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()) {
|
||||
let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?;
|
||||
s.activate(id)?;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ 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()),
|
||||
@@ -28,11 +31,41 @@ 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.
|
||||
file.sync_all()
|
||||
sync_file(file)
|
||||
});
|
||||
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.
|
||||
@@ -448,4 +481,41 @@ 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,6 +214,7 @@ 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();
|
||||
@@ -228,10 +229,23 @@ 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,3 +186,36 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! #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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1177,7 +1177,8 @@ impl Styles {
|
||||
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
|
||||
props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a),
|
||||
"text-properties" if in_default && cur.is_none() => {
|
||||
self.default_font = default_font(&a, &self.faces);
|
||||
// A later default style without a (usable) font keeps the earlier one (#4501).
|
||||
self.default_font = default_font(&a, &self.faces).or(self.default_font.take());
|
||||
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
|
||||
self.default_size = Some(sz.min(1638.0));
|
||||
}
|
||||
|
||||
@@ -1289,6 +1289,36 @@ fn meta_creation_date_timezones_and_fallbacks() {
|
||||
assert_eq!(m2.author, "Solo Creator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_default_style_without_a_font_keeps_the_default_font() {
|
||||
// #4501 follow-up: styles read twice (styles.xml, then content.xml) must not wipe the font.
|
||||
let mut styles = styles::Styles::default();
|
||||
let with_font = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let without = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
styles.read(with_font.as_bytes(), false);
|
||||
styles.read(without.as_bytes(), false);
|
||||
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_monospaced_default_style_keeps_the_proportional_default_font() {
|
||||
// #4501 follow-up: a monospaced default font is rejected and must not wipe the earlier one.
|
||||
let mut styles = styles::Styles::default();
|
||||
let with_font = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let mono = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Courier New"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
styles.read(with_font.as_bytes(), false);
|
||||
styles.read(mono.as_bytes(), false);
|
||||
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
|
||||
}
|
||||
|
||||
/// The `content.xml` of a package.
|
||||
pub(super) fn content_of(bytes: &[u8]) -> String {
|
||||
let mut z = archive(bytes);
|
||||
|
||||
@@ -7,8 +7,10 @@ use super::{
|
||||
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
|
||||
};
|
||||
|
||||
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
|
||||
/// The character style Word formats line numbers with: `w:styleId="LineNumber"` in English
|
||||
/// Word; other languages localise the id (German `Zeilennummer`) but keep the built-in name.
|
||||
const LINE_NUMBER_STYLE: &str = "LineNumber";
|
||||
const LINE_NUMBER_STYLE_NAME: &str = "line number";
|
||||
|
||||
/// A layout pass: finished, or to be done again with the page totals it found, or with the
|
||||
/// floats placed above their anchors it found ([`EarlyFloats`]).
|
||||
@@ -199,7 +201,11 @@ impl Ctx<'_> {
|
||||
// Word formats line numbers with the "Line Number" character style over the document's
|
||||
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
|
||||
// Aptos 12 pt), not in a fixed small grey.
|
||||
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
|
||||
let styles = &self.doc.styles;
|
||||
let style = styles
|
||||
.get(LINE_NUMBER_STYLE)
|
||||
.or_else(|| styles.find(LINE_NUMBER_STYLE_NAME).filter(|s| s.kind == wordcraft_doc::StyleKind::Character))
|
||||
.map(|s| s.id.clone());
|
||||
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
|
||||
p.props.space_after = Some(0.0);
|
||||
p.props.space_before = Some(0.0);
|
||||
|
||||
@@ -777,7 +777,8 @@ fn add_notes(pb: &mut PageBuilder, mut new_notes: Vec<NoteBox>, notes: &[(usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// Line numbers in the left margin for lines `l0..l1` of `pl` laid out from `y`.
|
||||
/// Line numbers for lines `l0..l1` of `pl` laid out from `y`, left of the column they are in:
|
||||
/// each column of a multi-column section gets its own (#6018), as in Word.
|
||||
fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1): (usize, usize), y: f32) -> Vec<Placed> {
|
||||
let mut items = Vec::new();
|
||||
if let Some(ln) = pb.sect.line_numbers.clone().filter(|_| !pb.web && pl.rp.style != "Header" && !pl.rp.suppress_line_numbers)
|
||||
@@ -800,7 +801,7 @@ fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1):
|
||||
para: num.clone(),
|
||||
l0: 0,
|
||||
l1: 1,
|
||||
x: pb.sect.margin_left + pb.sect.gutter - dist - w,
|
||||
x: pb.col_x() - dist - w,
|
||||
y: ly,
|
||||
turn: TextDirection::Horizontal,
|
||||
});
|
||||
|
||||
+28
-10
@@ -270,12 +270,21 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps),
|
||||
Tops { body, first, foot, first_foot }
|
||||
}
|
||||
|
||||
/// Set up the walk state for a section: its grid, field values and footnote separators.
|
||||
fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p>, totals: &Totals, web: bool) {
|
||||
let (doc, sect, si) = (ctx.doc, sec.sect, sec.si);
|
||||
/// Set up the walk state for a section before its header and footer room is measured: no grid yet
|
||||
/// (the previous section's must not leak into headers, footers and separators), the field values
|
||||
/// and the footnote separators.
|
||||
fn begin_section_state<'p>(
|
||||
ctx: &mut Ctx,
|
||||
pb: &mut PageBuilder<'p>,
|
||||
(si, sect, page_format): (usize, &'p SectionProps, wordcraft_doc::section::NumFormat),
|
||||
totals: &Totals,
|
||||
web: bool,
|
||||
) {
|
||||
let doc = ctx.doc;
|
||||
ctx.grid = para::Grid::default();
|
||||
pb.sect = sect;
|
||||
pb.page_format = sec.page_format;
|
||||
ctx.fields.page_format = sec.page_format;
|
||||
pb.page_format = page_format;
|
||||
ctx.fields.page_format = page_format;
|
||||
pb.sect_idx = si;
|
||||
pb.sect_beneath = doc.note_options(sect, false).pos == wordcraft_doc::section::NotePos::BeneathText;
|
||||
if !web && !doc.footnote_separators.is_empty() {
|
||||
@@ -283,7 +292,11 @@ fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p
|
||||
}
|
||||
ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1);
|
||||
ctx.fields.section_pages = totals.section(si);
|
||||
ctx.grid = para::Grid::of(doc, sect);
|
||||
}
|
||||
|
||||
/// The section's own grid and line numbering, once its header and footer room is known.
|
||||
fn apply_section_grid(ctx: &mut Ctx, pb: &mut PageBuilder<'_>, sect: &SectionProps) {
|
||||
ctx.grid = para::Grid::of(ctx.doc, sect);
|
||||
if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) {
|
||||
pb.line_no = 0;
|
||||
}
|
||||
@@ -541,17 +554,22 @@ pub(crate) fn layout_pass(
|
||||
_ if mode.reflow => sect_ref,
|
||||
_ => sect,
|
||||
};
|
||||
ctx.fields.page_format = page_format;
|
||||
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
|
||||
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
|
||||
let sec = Sec { si, sect, page_format, start, tops };
|
||||
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
|
||||
// The page the section before ends on is finished with its own margins and footnote
|
||||
// separators, before this section's replace them: the footnotes pending on it
|
||||
// belong to the section whose page it is (#5289).
|
||||
pb.finish_page();
|
||||
}
|
||||
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
|
||||
// This section's fields and separators, without the previous section's grid, before its
|
||||
// header and footer room is measured; its own grid once that room is known (#4501).
|
||||
begin_section_state(&mut ctx, &mut pb, (si, sect, page_format), totals, mode.web);
|
||||
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
|
||||
// Measuring the room took the (reflow) section's own number format; the body keeps the
|
||||
// document section's.
|
||||
ctx.fields.page_format = page_format;
|
||||
apply_section_grid(&mut ctx, &mut pb, sect);
|
||||
let sec = Sec { si, sect, page_format, start, tops };
|
||||
if mode.web && si > 0 {
|
||||
// one long page
|
||||
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
|
||||
|
||||
@@ -4055,6 +4055,76 @@ fn line_numbers_follow_the_line_number_style() {
|
||||
assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}");
|
||||
}
|
||||
|
||||
/// #6018: German Word saves the "line number" style as `Zeilennummer`; the numbers still take
|
||||
/// its formatting, found by the built-in name.
|
||||
#[test]
|
||||
fn line_numbers_follow_a_localised_line_number_style() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
d.last_section.line_numbers = Some(Default::default());
|
||||
let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0));
|
||||
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
|
||||
id: "Zeilennummer".into(),
|
||||
name: "Line Number".into(),
|
||||
kind: wordcraft_doc::StyleKind::Character,
|
||||
chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let l = lay(&d);
|
||||
let runs: Vec<_> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(runs.len(), 3);
|
||||
assert!(runs.iter().all(|(size, color)| *size == 8.0 && *color == red), "{runs:?}");
|
||||
}
|
||||
|
||||
/// #6018: in two columns each column has its numbers at its own left edge, as in Word; the
|
||||
/// second column's used to be drawn in the page margin over the first column's.
|
||||
#[test]
|
||||
fn line_numbers_stand_beside_each_column() {
|
||||
crate::test_fonts();
|
||||
let text = (1..=120).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
|
||||
let mut d = Document::from_text(&text);
|
||||
d.last_section.line_numbers = Some(Default::default());
|
||||
d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
|
||||
let s = d.last_section.clone();
|
||||
let col2 = s.margin_left + s.gutter + (s.text_width() - 36.0) / 2.0 + 36.0;
|
||||
let l = lay(&d);
|
||||
let p = &l.pages[0];
|
||||
// Right edge and top of every number.
|
||||
let numbers: Vec<(f32, f32)> = p
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story, para, x, y, .. } if *story == DECOR_STORY => {
|
||||
Some((x + para.lines.first().and_then(|l| l.xs.last().copied()).unwrap_or(0.0), *y))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let body_xs: Vec<f32> = p
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Body, x, .. } => Some(*x),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(body_xs.iter().any(|x| (x - col2).abs() < 0.5), "the text runs into the second column: {body_xs:?}");
|
||||
let in_col2 = body_xs.iter().filter(|x| (*x - col2).abs() < 0.5).count();
|
||||
let right: Vec<_> = numbers.iter().filter(|(r, _)| *r > s.margin_left + 1.0).collect();
|
||||
assert_eq!(right.len(), in_col2, "one number per second-column line");
|
||||
// Numbers end 18 pt (auto distance) left of their column.
|
||||
assert!(right.iter().all(|(r, _)| (r - (col2 - 18.0)).abs() < 0.5), "{right:?}");
|
||||
let left = numbers.len() - right.len();
|
||||
assert!(numbers.iter().filter(|(r, _)| *r <= s.margin_left + 1.0).all(|(r, _)| (r - (s.margin_left - 18.0)).abs() < 0.5));
|
||||
assert!(left > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_borders_text_boxes() {
|
||||
crate::test_fonts();
|
||||
|
||||
@@ -627,6 +627,61 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sections_header_is_measured_without_the_previous_sections_grid() {
|
||||
// #4501 follow-up: section 1 has a line grid, section 2 a tall header of its own. The header
|
||||
// room is measured before section 2's grid is on, so section 1's pitch never reaches it.
|
||||
let body_top = |grid: Option<wordcraft_doc::section::DocGrid>| {
|
||||
let mut d = Document::from_text("First section\nSecond section");
|
||||
let id = d.add_part(PartKind::Header, paras("Header line", 8));
|
||||
d.last_section.headers.default = Some(id);
|
||||
let first = SectionProps { doc_grid: grid, headers: Default::default(), ..d.last_section.clone() };
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 2);
|
||||
l.pages[1].body.y
|
||||
};
|
||||
let grid = wordcraft_doc::section::DocGrid { kind: wordcraft_doc::section::DocGridType::Lines, line_pitch: 18.0, char_space: 0 };
|
||||
let plain = body_top(None);
|
||||
assert!(plain > 72.0, "the header reaches past the top margin: {plain}");
|
||||
assert_eq!(body_top(Some(grid)), plain, "section 1's grid leaked into section 2's header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sections_header_is_measured_with_its_own_section_page_count() {
|
||||
// #4501 follow-up: section 1 has one page ("one"), section 2 twenty-three ("twenty-three").
|
||||
// Section 2's header room is measured with its own SECTIONPAGES, so its body starts below it.
|
||||
// (The total in the body makes layout run again with the page totals found.)
|
||||
let mut d = Document::from_text(&format!("First section\n{}", "Second section\n".repeat(22)));
|
||||
for i in 1..d.body.len() {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(i > 1);
|
||||
}
|
||||
let field = InlineObject::Field {
|
||||
instr: r"SECTIONPAGES \* CardText".into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: true,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
let first_para = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
first_para.insert_object(first_para.len(), field.clone(), &Default::default()).unwrap();
|
||||
let mut header = Paragraph::new();
|
||||
for _ in 0..60 {
|
||||
header.insert_object(header.len(), field.clone(), &Default::default()).unwrap();
|
||||
}
|
||||
let id = d.add_part(PartKind::Header, vec![para_block(header)]);
|
||||
d.last_section.headers.default = Some(id);
|
||||
let first = SectionProps { headers: Default::default(), ..d.last_section.clone() };
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 24);
|
||||
let p = &l.pages[1];
|
||||
let (_, header_bottom) = extent(&p.header, StoryRef::Part(id)).unwrap();
|
||||
assert!(header_bottom > 72.0, "the header reaches past the top margin: {header_bottom}");
|
||||
assert!(header_bottom <= p.body.y + 0.01, "the body starts at {} inside the header ending at {header_bottom}", p.body.y);
|
||||
}
|
||||
|
||||
/// A body PAGE field shows the page number in the section's number format, as Word does (a
|
||||
/// cardinal-text section shows "one"), and `\*` overrides it.
|
||||
#[test]
|
||||
|
||||
@@ -321,7 +321,9 @@ 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 r = Rect::from_min_size(tile.min, vec2(150.0, 194.0));
|
||||
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));
|
||||
ui.painter().rect(
|
||||
r,
|
||||
2.0,
|
||||
@@ -352,7 +354,8 @@ 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);
|
||||
}
|
||||
ui.painter().galley(pos2(r.min.x, r.max.y), galley, ui.visuals().text_color());
|
||||
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());
|
||||
if resp.clicked() {
|
||||
let _ = app.run("file.new", json!({"template": template}));
|
||||
app.ui.backstage = false;
|
||||
|
||||
@@ -1156,3 +1156,139 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,7 +175,9 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
|
||||
return;
|
||||
}
|
||||
if pressed {
|
||||
handle_primary_press(app, &pointer, mods, hf_zone, hf_enter);
|
||||
if handle_primary_press(app, &pointer, mods, hf_zone, hf_enter) {
|
||||
return;
|
||||
}
|
||||
} else if app.canvas.dragging
|
||||
&& ui.input(|i| i.pointer.primary_down())
|
||||
&& let Some((from, last)) = app.canvas.column_drag
|
||||
@@ -229,13 +231,14 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
|
||||
show_hover_link(app, &pointer);
|
||||
}
|
||||
|
||||
/// A press. `true` when it was handled and the pointer is done (no hover tooltip this frame).
|
||||
fn handle_primary_press(
|
||||
app: &mut WordApp,
|
||||
pointer: &PagePointer<'_>,
|
||||
mods: egui::Modifiers,
|
||||
hf_zone: Option<(Option<StoryRef>, bool)>,
|
||||
hf_enter: bool,
|
||||
) {
|
||||
) -> bool {
|
||||
let PagePointer { ui, layout, p, page, x, y, story, .. } = *pointer;
|
||||
let now = ui.input(|i| i.time);
|
||||
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
|
||||
@@ -269,14 +272,14 @@ fn handle_primary_press(
|
||||
}
|
||||
app.session.page_hint = page;
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// A press in the header/footer area from the body leaves the caret where it is, as in
|
||||
// Word (#2103): moving it to the nearest body text scrolled the view there, away from the
|
||||
// footer the double-click was aiming at.
|
||||
if hf_switch && !mods.shift && hf_zone.is_some() && story == StoryRef::Body && app.session.sel.focus.story == StoryRef::Body {
|
||||
app.canvas.column_drag = None;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if !mods.shift
|
||||
&& let Some((at, inner)) = layout.equation_hit(page, x, y, story)
|
||||
@@ -284,7 +287,7 @@ fn handle_primary_press(
|
||||
app.session.sel = wordcraft_engine::Selection::caret(at);
|
||||
let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()}));
|
||||
app.session.page_hint = page;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if app.session.math.is_some() {
|
||||
let _ = app.run("equation.exit", json!({}));
|
||||
@@ -324,15 +327,15 @@ fn handle_primary_press(
|
||||
};
|
||||
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line });
|
||||
app.canvas.dragging = true;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return };
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return true };
|
||||
// Ctrl/⌘+click follows a hyperlink.
|
||||
if mods.command
|
||||
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
|
||||
{
|
||||
follow_link(app, link);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
||||
// Alt+drag selects a column (block) of text.
|
||||
@@ -341,7 +344,7 @@ fn handle_primary_press(
|
||||
app.session.page_hint = page;
|
||||
app.canvas.column_drag = Some(((page, x, y), (page, x, y)));
|
||||
app.canvas.dragging = true;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
app.canvas.column_drag = None;
|
||||
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
|
||||
@@ -362,6 +365,7 @@ fn handle_primary_press(
|
||||
app.session.page_hint = page;
|
||||
app.canvas.dragging = true;
|
||||
// Format painter applies on mouse up
|
||||
false
|
||||
}
|
||||
|
||||
fn pointer_story(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, story: StoryRef) -> StoryRef {
|
||||
|
||||
@@ -2887,6 +2887,40 @@ fn right_clicking_a_link_hides_the_hover_tooltip() {
|
||||
assert_eq!(tips(&h), 0, "the link tooltip is gone, so it can't cover the menu");
|
||||
}
|
||||
|
||||
/// #4501 follow-up: a press the canvas handles (here Ctrl+click following a link) is done with
|
||||
/// the pointer, so the link tooltip stands down that frame; plain hovering shows it again.
|
||||
#[test]
|
||||
fn a_handled_press_skips_the_link_tooltip() {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("See here")), Default::default());
|
||||
a.run("select.text", json!({"text": "here"})).unwrap();
|
||||
a.run("insert.link", json!({"url": "https://example.com/"})).unwrap();
|
||||
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);
|
||||
},
|
||||
a,
|
||||
);
|
||||
steps(&mut h, 6);
|
||||
let c = h.state_mut().session.layout().caret(&Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 5 }).unwrap();
|
||||
let at = screen(&mut h, c.x, c.top + c.height / 2.0);
|
||||
h.event(Event::PointerMoved(at));
|
||||
steps(&mut h, 2);
|
||||
let tips = |h: &egui_kittest::Harness<'_, WordApp>| h.query_all_by_label_contains("Click to follow link").count();
|
||||
assert!(tips(&h) > 0, "hovering the link shows its target");
|
||||
h.event(Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::COMMAND });
|
||||
h.step();
|
||||
assert_eq!(tips(&h), 0, "the Ctrl+click was handled: no tooltip that frame");
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::COMMAND });
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
steps(&mut h, 2);
|
||||
assert!(tips(&h) > 0, "hovering again shows the tooltip");
|
||||
}
|
||||
|
||||
/// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves
|
||||
/// the scrollable range.
|
||||
#[test]
|
||||
|
||||
@@ -67,6 +67,10 @@ 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 {
|
||||
@@ -85,10 +89,12 @@ impl CaptureGeometry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Explains why the surface cannot be captured, when it is minimized or fully occluded, and
|
||||
/// returns `None` when it can.
|
||||
/// 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.
|
||||
pub(crate) fn unavailable_reason(self) -> Option<&'static str> {
|
||||
if self.surface.minimized {
|
||||
if self.viewport != egui::ViewportId::ROOT {
|
||||
Some(UNSUPPORTED_SECONDARY_WINDOW_ERROR)
|
||||
} else 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;
|
||||
use super::geometry::{CaptureGeometry, UNSUPPORTED_SECONDARY_WINDOW_ERROR};
|
||||
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,6 +128,19 @@ 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,11 +12,16 @@ 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;
|
||||
@@ -25,6 +30,8 @@ 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),
|
||||
@@ -35,8 +42,10 @@ 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",
|
||||
@@ -51,6 +60,7 @@ 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",
|
||||
@@ -68,6 +78,7 @@ 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)),
|
||||
@@ -84,6 +95,7 @@ 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),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 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,3 +166,155 @@ 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,6 +235,7 @@ 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"),
|
||||
c("Date & Time", "insert.dateTime"),
|
||||
d("Date & Time", "dateTime"),
|
||||
c("Object", "insert.object"),
|
||||
c("Equation", "insert.equation"),
|
||||
m("Symbol", "insert.symbol"),
|
||||
|
||||
@@ -294,6 +294,12 @@ 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:
|
||||
@@ -445,6 +451,7 @@ 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(),
|
||||
@@ -455,6 +462,9 @@ 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(),
|
||||
|
||||
@@ -70,7 +70,10 @@ use shape::shape_format;
|
||||
use table::{table_design, table_layout};
|
||||
#[cfg(test)]
|
||||
use tabs::MIN_TAB_W;
|
||||
use tabs::{MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, tab_entry};
|
||||
use tabs::{
|
||||
CONTENT_SWITCH_SLIDE, MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, slide_underline,
|
||||
tab_content_entry, tab_entry,
|
||||
};
|
||||
pub use tabs::{
|
||||
StripPlan, TABS, TabSpec, contextual_tabs, fit_tabs, has_chart_selected, has_picture_selected, has_shape_selected, has_smart_art_selected,
|
||||
in_table_public, plan_strip, resolve_tab,
|
||||
@@ -277,8 +280,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
let active = app.ui.tab == tab && !app.ui.backstage;
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
|
||||
if resp.hovered() && !active && ghost.is_none() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && !active && ghost.is_none());
|
||||
if hover > 0.0 {
|
||||
ui.painter().rect_filled(
|
||||
r.shrink2(vec2(0.0, 4.0)),
|
||||
4.0,
|
||||
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
|
||||
);
|
||||
}
|
||||
// Entering and departing contextual tabs move and fade without changing layout.
|
||||
let entry = match ghost {
|
||||
@@ -316,7 +324,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let resp = if cut && ghost.is_none() { resp.on_hover_text(shown) } else { resp };
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x + shift.x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||||
ui.painter().rect_filled(u, 2.0, t.accent.gamma_multiply(entry));
|
||||
slide_underline(ui, tab, u, t.accent.gamma_multiply(entry), now, animate);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
@@ -337,8 +345,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
.collect();
|
||||
if !left_out.is_empty() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered());
|
||||
if hover > 0.0 {
|
||||
ui.painter().rect_filled(
|
||||
r.shrink2(vec2(0.0, 4.0)),
|
||||
4.0,
|
||||
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
|
||||
);
|
||||
}
|
||||
// The active tab is in the menu: the button says so.
|
||||
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
|
||||
@@ -376,6 +389,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
// Bring newly selected ribbon content in from the side of its tab.
|
||||
let index = tabs.iter().position(|(name, _)| name == &app.ui.tab).unwrap_or(0);
|
||||
let (settled, direction) = tab_content_entry(ui.ctx(), &app.ui.tab, index, now, animate);
|
||||
let shift = vec2(direction * (1.0 - settled) * CONTENT_SWITCH_SLIDE, 0.0);
|
||||
let mut shifted = ui.new_child(egui::UiBuilder::new().max_rect(ui.max_rect().translate(shift)).layout(*ui.layout()));
|
||||
shifted.set_opacity(0.2 + 0.8 * settled);
|
||||
let ui = &mut shifted;
|
||||
// Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so
|
||||
// the buttons drawn over it keep their own clicks.
|
||||
// No document open (#1129): every command is greyed out but Help's.
|
||||
|
||||
@@ -44,6 +44,7 @@ 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| {
|
||||
@@ -189,7 +190,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("insert.dateTime", json!({}), false));
|
||||
small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("ui.dialog", json!({"name": "dateTime"}), false));
|
||||
small(ui, app, "object", None, "Object", crate::widgets::ButtonAction::new("insert.object", json!({}), false));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +158,78 @@ pub(super) fn ease_out_cubic(p: f32) -> f32 {
|
||||
1.0 - (1.0 - p.clamp(0.0, 1.0)).powi(3)
|
||||
}
|
||||
|
||||
const UNDERLINE_SLIDE_SECS: f64 = 0.2;
|
||||
const CONTENT_SWITCH_SECS: f64 = 0.16;
|
||||
pub(super) const CONTENT_SWITCH_SLIDE: f32 = 14.0;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Underline {
|
||||
tab: String,
|
||||
from: (f32, f32),
|
||||
to: (f32, f32),
|
||||
start: f64,
|
||||
}
|
||||
|
||||
fn underline_at(u: &Underline, now: f64) -> (f32, f32) {
|
||||
let e = ease_out_cubic(((now - u.start) / UNDERLINE_SLIDE_SECS) as f32);
|
||||
(egui::lerp(u.from.0..=u.to.0, e), egui::lerp(u.from.1..=u.to.1, e))
|
||||
}
|
||||
|
||||
pub(super) fn slide_underline(ui: &egui::Ui, tab: &str, target: egui::Rect, color: egui::Color32, now: f64, animate: bool) {
|
||||
let id = egui::Id::unique("tab_underline");
|
||||
let prev: Option<Underline> = ui.ctx().data(|d| d.get_temp(id));
|
||||
let snap = Underline { tab: tab.to_owned(), from: (target.center().x, target.width()), to: (target.center().x, target.width()), start: now };
|
||||
let next = match prev {
|
||||
Some(p) if animate && p.tab != tab => Underline { from: underline_at(&p, now), ..snap },
|
||||
Some(p) if animate && now - p.start < UNDERLINE_SLIDE_SECS => Underline { to: (target.center().x, target.width()), ..p },
|
||||
_ => snap,
|
||||
};
|
||||
let (x, width) = underline_at(&next, now);
|
||||
if next.from != next.to && now - next.start < UNDERLINE_SLIDE_SECS {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, next));
|
||||
ui.painter().rect_filled(egui::Rect::from_center_size(egui::pos2(x, target.center().y), egui::vec2(width, target.height())), 2.0, color);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct TabChange {
|
||||
tab: String,
|
||||
index: usize,
|
||||
start: f64,
|
||||
dir: f32,
|
||||
}
|
||||
|
||||
pub(super) fn tab_content_entry(ctx: &egui::Context, tab: &str, index: usize, now: f64, animate: bool) -> (f32, f32) {
|
||||
let id = egui::Id::unique("ribbon_tab_change");
|
||||
let prev: Option<TabChange> = ctx.data(|d| d.get_temp(id));
|
||||
let next = match prev {
|
||||
Some(p) if p.tab != tab => TabChange {
|
||||
tab: tab.to_owned(),
|
||||
index,
|
||||
start: now,
|
||||
dir: if !animate || p.index == index {
|
||||
0.0
|
||||
} else if index > p.index {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
},
|
||||
},
|
||||
Some(p) => p,
|
||||
None => TabChange { tab: tab.to_owned(), index, start: f64::MIN, dir: 0.0 },
|
||||
};
|
||||
let p = ((now - next.start) / CONTENT_SWITCH_SECS).clamp(0.0, 1.0) as f32;
|
||||
let dir = next.dir;
|
||||
ctx.data_mut(|d| d.insert_temp(id, next));
|
||||
if p < 1.0 && dir != 0.0 {
|
||||
ctx.request_repaint();
|
||||
(ease_out_cubic(p), dir)
|
||||
} else {
|
||||
(1.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Track contextual tabs between frames and return those still animating out.
|
||||
pub(super) fn note_contextual_tabs(ctx: &egui::Context, shown: &[(&str, usize)], now: f64, animate: bool) -> Vec<(String, usize, f64)> {
|
||||
let (shown_id, leaving_id) = (egui::Id::unique("contextual_tabs_shown"), egui::Id::unique("contextual_tabs_leaving"));
|
||||
@@ -346,4 +418,40 @@ mod transition_tests {
|
||||
note_contextual_tabs(&ctx, &both[..1], 7.0, true);
|
||||
assert!(note_contextual_tabs(&ctx, &both, 7.1, true).is_empty(), "back again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_underline_slides_and_can_reverse_mid_motion() {
|
||||
let ctx = egui::Context::default();
|
||||
let draw = |tab: &str, x: f32, now: f64| {
|
||||
ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| {
|
||||
slide_underline(
|
||||
ui,
|
||||
tab,
|
||||
egui::Rect::from_min_size(egui::pos2(x - 25.0, 10.0), egui::vec2(50.0, 3.0)),
|
||||
egui::Color32::WHITE,
|
||||
now,
|
||||
true,
|
||||
);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
draw("Home", 40.0, 1.0);
|
||||
draw("Insert", 140.0, 2.0);
|
||||
let frame = ctx.run_ui(egui::RawInput { time: Some(2.1), ..Default::default() }, |ui| {
|
||||
slide_underline(ui, "Draw", egui::Rect::from_min_size(egui::pos2(215.0, 10.0), egui::vec2(50.0, 3.0)), egui::Color32::WHITE, 2.1, true);
|
||||
});
|
||||
assert!(!frame.shapes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_content_motion_comes_from_the_new_tab_side() {
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(tab_content_entry(&ctx, "Home", 1, 1.0, true), (1.0, 0.0));
|
||||
let (p, dir) = tab_content_entry(&ctx, "Insert", 2, 2.0, true);
|
||||
assert_eq!((p, dir), (0.0, 1.0));
|
||||
let (p, dir) = tab_content_entry(&ctx, "Home", 1, 2.05, true);
|
||||
assert!(p < 1.0);
|
||||
assert_eq!(dir, -1.0);
|
||||
assert_eq!(tab_content_entry(&ctx, "Draw", 3, 3.0, false), (1.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +35,19 @@ pub fn enabled(app: &WordApp, id: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
|
||||
let fill = if resp.is_pointer_button_down_on() {
|
||||
bg_if(ui, r, resp, checked, t, true);
|
||||
}
|
||||
|
||||
/// Paints a button background with an optional hover/press response. Disabled ribbon commands
|
||||
/// pass `false` so their visual state cannot imply that they are interactive.
|
||||
pub(crate) fn bg_if(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens, on: bool) {
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && on);
|
||||
let fill = if resp.is_pointer_button_down_on() && on {
|
||||
t.pressed
|
||||
} else if checked {
|
||||
t.checked
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else if hover > 0.0 {
|
||||
Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
@@ -105,7 +112,7 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str,
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
keytip_badge(ui, r, id, &label.replace('\n', " "));
|
||||
let on = enabled(app, id);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
bg_if(ui, r, &resp, checked, &t, on);
|
||||
let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
@@ -146,7 +153,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click());
|
||||
keytip_badge(ui, r, id, tip);
|
||||
let on = enabled(app, id);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
bg_if(ui, r, &resp, checked, &t, on);
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let ic = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y), vec2(17.0, 17.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
@@ -181,8 +188,8 @@ pub fn split(
|
||||
keytip_badge(ui, r, id, tip);
|
||||
let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click());
|
||||
let on = enabled(app, id);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
bg(ui, ar, &aresp, false, &t);
|
||||
bg_if(ui, r, &resp, checked, &t, on);
|
||||
bg_if(ui, ar, &aresp, false, &t, on);
|
||||
let ic = Rect::from_center_size(r.center(), vec2(17.0, 17.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
@@ -252,7 +259,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
|
||||
let w = (galley_w + 22.0).max(44.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
keytip_badge(ui, r, "menu", tip);
|
||||
bg(ui, r, &resp, false, &t);
|
||||
bg_if(ui, r, &resp, false, &t, on);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)), icon, c, a);
|
||||
let lines: Vec<&str> = label.unwrap_or("").split('\n').collect();
|
||||
let mut y = r.min.y + BIG_LABEL_Y;
|
||||
@@ -265,7 +272,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
|
||||
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click());
|
||||
keytip_badge(ui, r, "menu", tip);
|
||||
bg(ui, r, &resp, false, &t);
|
||||
bg_if(ui, r, &resp, false, &t, on);
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let lead = |inset: f32| crate::rtl::lead_x(rtl, r, inset);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(lead(12.0), r.center().y), vec2(17.0, 17.0)), icon, c, a);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#[cfg(test)]
|
||||
use super::TALL_GROUPS;
|
||||
use super::buttons::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, DROPDOWN, LABEL_H, bg, label_line};
|
||||
use super::{Align2, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
|
||||
use super::{Align2, Color32, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
|
||||
|
||||
/// A ribbon group: content, a centred label below and a divider on the right.
|
||||
pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
|
||||
@@ -26,6 +26,8 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
// Not folded: a drop-down left open while it was folded doesn't come back by itself.
|
||||
ui.data_mut(|d| d.remove::<bool>(collapsed_open_id(ui, id_title, position)));
|
||||
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
|
||||
// Reserve the paint slot before children so the glow stays beneath the controls.
|
||||
let glow = ui.painter().add(egui::Shape::Noop);
|
||||
let inner = crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 });
|
||||
crate::ribbon_fit::set_compact(ui.ctx(), level == crate::ribbon_fit::COMPACT);
|
||||
@@ -42,6 +44,13 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
let _ = content;
|
||||
});
|
||||
let r = inner.response.rect;
|
||||
let area = group_area(rtl, start, r);
|
||||
let over = ui.rect_contains_pointer(area);
|
||||
// Position is part of identity because duplicate custom group names can appear in tabs.
|
||||
let lit = ui.ctx().animate_bool_responsive(ui.scope_id().with(("group_glow", id_title, position)), over);
|
||||
if lit > 0.0 {
|
||||
ui.painter().set(glow, group_glow(area, t.accent, lit));
|
||||
}
|
||||
title_row(
|
||||
ui,
|
||||
Rect::from_min_max(pos2(if rtl { r.min.x } else { start.x }, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)),
|
||||
@@ -58,6 +67,13 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
crate::ribbon_fit::record(ui.ctx(), id_title, level, group_width(rtl, start_cursor, ui.cursor()));
|
||||
}
|
||||
|
||||
/// The hover area of a group. A right-to-left row has no finite left edge at the cursor, so the
|
||||
/// area starts at the content's own left edge there.
|
||||
fn group_area(rtl: bool, start: egui::Pos2, content: Rect) -> Rect {
|
||||
let left = if rtl { content.min.x } else { start.x };
|
||||
Rect::from_min_max(pos2(left, start.y), pos2(content.max.x, start.y + CONTENT_H + LABEL_H))
|
||||
}
|
||||
|
||||
/// The width a group took: the cursor's way along the row, from `before` to `after`. A
|
||||
/// right-to-left row fills from its right edge, where the cursor's left edge is negative infinity,
|
||||
/// so there the width is how far the right edge moved.
|
||||
@@ -65,6 +81,25 @@ fn group_width(rtl: bool, before: Rect, after: Rect) -> f32 {
|
||||
if rtl { before.max.x - after.max.x } else { after.min.x - before.min.x }
|
||||
}
|
||||
|
||||
/// A soft accent glow along the bottom of a hovered ribbon group.
|
||||
fn group_glow(area: Rect, accent: Color32, strength: f32) -> egui::Shape {
|
||||
let color = |alpha: f32| Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (alpha * strength * 255.0) as u8);
|
||||
let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 7.0), pos2(area.max.x, area.max.y + 2.0));
|
||||
let soft = (r.width() * 0.3).min(22.0);
|
||||
let xs = [r.min.x, r.min.x + soft, r.max.x - soft, r.max.x];
|
||||
let mut mesh = egui::epaint::Mesh::default();
|
||||
for (y, alpha) in [(r.max.y, 0.3), (r.min.y, 0.0)] {
|
||||
for (i, x) in xs.iter().enumerate() {
|
||||
mesh.colored_vertex(pos2(*x, y), color(if i == 0 || i == 3 { 0.0 } else { alpha }));
|
||||
}
|
||||
}
|
||||
for i in 0..3u32 {
|
||||
mesh.add_triangle(i, i + 1, i + 5);
|
||||
mesh.add_triangle(i, i + 5, i + 4);
|
||||
}
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
|
||||
/// A group's title row (`r`): the centred title and, with a `launcher`, its dialog launcher.
|
||||
fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -264,4 +299,15 @@ mod tests {
|
||||
let after = Rect::from_min_max(pos2(180.0, 0.0), pos2(f32::INFINITY, 90.0));
|
||||
assert_eq!(group_width(false, before, after), 160.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_in_a_right_to_left_row_has_a_finite_hover_area() {
|
||||
let start = pos2(f32::NEG_INFINITY, 10.0);
|
||||
let content = Rect::from_min_max(pos2(600.0, 10.0), pos2(740.0, 80.0));
|
||||
let area = group_area(true, start, content);
|
||||
assert!(area.min.x.is_finite() && area.max.x.is_finite());
|
||||
assert_eq!((area.min.x, area.max.x), (600.0, 740.0));
|
||||
let next = group_area(true, start, Rect::from_min_max(pos2(450.0, 10.0), pos2(590.0, 80.0)));
|
||||
assert!(!area.intersects(next), "neighbouring groups do not overlap");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +261,9 @@ impl WordApp {
|
||||
quick_access_toolbar: _,
|
||||
recent: _,
|
||||
recent_symbols: _,
|
||||
date_time_format: _,
|
||||
date_time_language: _,
|
||||
date_time_update: _,
|
||||
recent_colors: _,
|
||||
recent_fonts: _,
|
||||
theme: _,
|
||||
|
||||
@@ -33,6 +33,7 @@ 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};
|
||||
|
||||
@@ -306,6 +307,17 @@ 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) {
|
||||
@@ -663,6 +675,15 @@ 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,6 +71,7 @@ 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;
|
||||
@@ -99,6 +100,11 @@ 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,11 +386,14 @@ fn control_typing_reaches_the_active_window() {
|
||||
assert_eq!(text(&a), "First", "the other window got none of it");
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// `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.
|
||||
#[test]
|
||||
fn screenshot_captures_the_active_window() {
|
||||
fn screenshot_captures_the_root_window_and_refuses_secondary_windows() {
|
||||
let ctx = backend();
|
||||
let mut a = app("First");
|
||||
frames(&ctx, &mut a, 2);
|
||||
@@ -403,26 +406,102 @@ fn screenshot_captures_the_active_window() {
|
||||
|
||||
let (reply, replies) = std::sync::mpsc::channel();
|
||||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||||
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])]);
|
||||
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);
|
||||
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(child), "the active window is captured");
|
||||
assert!(!shot_to(ViewportId::ROOT), "the first window is not");
|
||||
assert!(shot_to(ViewportId::ROOT), "the root window is captured");
|
||||
assert!(!shot_to(child), "the child 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);
|
||||
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());
|
||||
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");
|
||||
}
|
||||
|
||||
/// Quitting with two documents of unsaved changes asks about each in its window, the window
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Control protocol
|
||||
|
||||
> **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.
|
||||
> **Last updated:** 2026-11-09 · #6159: refuse ui.screenshot on secondary document windows and support root window selection with view.switchWindows.
|
||||
|
||||
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,9 +72,11 @@ 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?}` 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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -93,7 +95,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: written to `path`, or without one returned as base64 `png` in the reply |
|
||||
| `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.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 |
|
||||
@@ -131,7 +133,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.
|
||||
the front. With `{"window": key}` it selects that window directly and brings it to 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
|
||||
@@ -154,6 +156,14 @@ 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).
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user