Files
wordcraft/crates/ui-egui/src/file_dialogs.rs
T
aiongg 833a75d59c Merge PR #3434: Save As no longer swallows a failed save silently
crates/ui-egui/src/lib.rs: kept main's removal of the Focus Escape block from logic() (Escape handling moved in #1733); took #3434's comment on the Recent/Save As error reset.
2026-10-22 01:22:25 +00:00

1197 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File dialogs (Open, Save As, Export, Insert/Change Picture) that don't freeze the window (#94).
//!
//! A host with [`Services::file_dialog`](crate::Services::file_dialog) shows the native dialog
//! without blocking the UI thread and sends the answer back later; [`WordApp::logic`] picks it up
//! on a later frame and finishes what the dialog was for (open the file, save and carry on with a
//! pending New/Open/Close, export, insert the picture). A host with only the blocking
//! `pick_open` / `pick_save` gets the same follow-up straight away. One dialog at a time: asking
//! for another while one is open does nothing.
//!
//! When the host can't show a system dialog at all (a Linux desktop with neither a file chooser
//! portal nor zenity, #576) it answers with an error, and the app finishes the job in its own
//! [`file_browser`](crate::file_browser) instead.
use std::sync::mpsc::{Receiver, TryRecvError};
use serde_json::{Value, json};
use crate::WordApp;
/// A file dialog the app asks the host to show ([`Services::file_dialog`](crate::Services::file_dialog)).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileDialogRequest {
/// Pick a file to open; `purpose` says what for (`document`, `picture`, `recipients`, `font`,
/// `sources`), as for `pick_open`.
Open { purpose: String },
/// Pick one or more files (Add Font…); the answer lists every one picked.
OpenMany { purpose: String },
/// Pick a path to save to, given a suggested file name and, if it matters, the folder to
/// start in (Save as Word Template starts in the user templates folder, #2852).
Save { name: String, dir: Option<String> },
}
/// The host's answer to a [`FileDialogRequest`]: the picked paths (one for everything but
/// [`FileDialogRequest::OpenMany`]), empty when the user cancelled, or an error when no system
/// dialog could be shown (the app then shows its own).
pub type FileDialogAnswer = Result<Vec<String>, String>;
/// Most font files one Add Font pick tries; the rest of a bigger selection is skipped.
const MAX_FONTS_PER_PICK: usize = 2000;
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
/// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change
/// Picture, recipient lists for Select Recipients › Use an Existing List…, documents otherwise.
/// The host's system dialog and the built-in browser offer the same.
pub fn open_filters(purpose: &str) -> &'static [(&'static str, &'static [&'static str])] {
match purpose {
"picture" => &[("Pictures", PICTURES)],
"font" => &[("Fonts", wordcraft_fonts::FONT_FILE_EXTS)],
"sources" => &[("Word sources", &["xml"]), ("All files", &["*"])],
"recipients" => &[("Recipient lists", wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS), ("All files", &["*"])],
_ => &[("Documents", DOCUMENTS), ("Word document", &["docx"]), ("All files", &["*"])],
}
}
/// What to do with the path picked in a dialog.
pub(crate) enum AfterPick {
/// Open the picked document.
OpenDocument,
/// Insert the picked picture, or replace the pending Change Picture target.
Picture,
/// Save the document there, then carry on.
SaveAs { after: AfterSave, document: u64 },
/// Save a copy there in this format (`pdf`, `docx`, `png`…).
Export { ext: String },
/// Write the PDF of these print settings there (File › Print › Save these pages as PDF).
PrintPdf { settings: Value },
/// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334).
Recipients,
/// Add every picked font file (#582, #1789).
Font,
/// Import the picked Word bibliography file (`b:Sources`: Word's `Sources.xml`) into the
/// document through Manage Sources (#2640).
#[cfg(not(target_arch = "wasm32"))]
Sources,
/// Hand the picked path to the open dialog (`WordApp::dialog_pick`): Templates and Add-ins,
/// Organizer (#377).
ForDialog,
/// Put the picked document into the Compare dialog (original or revised, #412).
ComparePath { revised: bool },
}
/// What to do once Save As has written the document.
pub(crate) enum AfterSave {
Nothing,
/// The AutoSave switch asked for the save: turn AutoSave on.
AutoSave,
/// "Save changes?" answered Save: run the command it held back (New, Open, Close…), as long as
/// the document is safely written and is still the one the prompt asked about.
Continue {
then: String,
params: Value,
document: u64,
},
}
/// A dialog the host is showing; the answer arrives on `rx` (`None`: cancelled).
pub(crate) struct PendingDialog {
rx: Receiver<FileDialogAnswer>,
req: FileDialogRequest,
after: AfterPick,
}
/// How asking for a file went.
pub(crate) enum Asked {
/// Another file dialog is still open; nothing happened.
Busy,
/// The host shows the dialog; the answer is handled on a later frame.
Pending,
/// The host answered straight away and the follow-up ran.
Done(Result<Value, String>),
}
impl WordApp {
/// Whether a file dialog is open (its answer hasn't arrived yet), the host's or the built-in one.
pub fn file_dialog_open(&self) -> bool {
self.file_dialog.is_some() || self.file_browser_open()
}
#[cfg(not(target_arch = "wasm32"))]
fn file_browser_open(&self) -> bool {
self.file_browser.open.is_some()
}
#[cfg(target_arch = "wasm32")]
fn file_browser_open(&self) -> bool {
false
}
/// Ask the host for a file and do `after` with the answer: now for a blocking dialog, on a
/// later frame for an asynchronous one. Ignored while another dialog is open.
pub(crate) fn ask_file(&mut self, req: FileDialogRequest, after: AfterPick) -> Asked {
if self.file_dialog_open() {
log::debug!("file dialog already open; ignoring {req:?}");
self.status(tl!("A file dialog is already open."));
return Asked::Busy;
}
if let Some(show) = &self.services.file_dialog {
let rx = show(req.clone());
self.file_dialog = Some(PendingDialog { rx, req, after });
// A host that answered at once (or failed to show anything) is handled right away.
return self.poll_file_dialog().map_or(Asked::Pending, Asked::Done);
}
let picked = match &req {
FileDialogRequest::Open { purpose } | FileDialogRequest::OpenMany { purpose } => {
self.services.pick_open.as_ref().and_then(|f| f(purpose))
}
FileDialogRequest::Save { name, .. } => self.services.pick_save.as_ref().and_then(|f| f(name)),
};
Asked::Done(self.finish_pick(after, picked))
}
/// Pick up the answer of an open file dialog, if it has arrived (every frame, from `logic`),
/// and finish what it was for. `None` while there is nothing to finish.
pub(crate) fn poll_file_dialog(&mut self) -> Option<Result<Value, String>> {
let answer = match self.file_dialog.as_ref().map(|d| d.rx.try_recv()) {
None | Some(Err(TryRecvError::Empty)) => return None,
Some(Ok(answer)) => answer,
// The host dropped the dialog without an answer: as good as cancelled.
Some(Err(TryRecvError::Disconnected)) => Ok(Vec::new()),
};
let d = self.file_dialog.take()?;
match answer {
Ok(picked) => Some(self.finish_picks(d.after, picked)),
Err(reason) => {
log::warn!("no system file dialog ({reason}); showing the built-in file browser");
self.dialog_unavailable(d.req, d.after)
}
}
}
/// Treat the system dialog the host is showing as cancelled, e.g. one that never showed up.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn give_up_file_dialog(&mut self) {
if let Some(d) = self.file_dialog.take() {
let _ = self.finish_pick(d.after, None);
}
}
/// The host couldn't show a system dialog (#576): ask in the built-in browser instead, which
/// finishes the job later. `None` while it is open.
#[cfg(not(target_arch = "wasm32"))]
fn dialog_unavailable(&mut self, req: FileDialogRequest, after: AfterPick) -> Option<Result<Value, String>> {
self.show_file_browser(req, after, true);
None
}
/// The browser has no built-in file browser (it can't list the user's folders): cancelled.
#[cfg(target_arch = "wasm32")]
fn dialog_unavailable(&mut self, _req: FileDialogRequest, after: AfterPick) -> Option<Result<Value, String>> {
Some(self.finish_pick(after, None))
}
/// Do what the dialog was for with the picked paths (none: the user cancelled). Only Add Font
/// takes more than one; the others use the first.
fn finish_picks(&mut self, after: AfterPick, picked: Vec<String>) -> Result<Value, String> {
match after {
AfterPick::Font if !picked.is_empty() => self.add_fonts(picked),
after => self.finish_pick(after, picked.into_iter().next()),
}
}
/// Add each picked font file, going on past the ones that fail, and say how it went in one
/// status line (#1789). One file keeps `font.add`'s own result and message.
fn add_fonts(&mut self, mut paths: Vec<String>) -> Result<Value, String> {
if paths.len() == 1 {
return self.run("font.add", json!({"path": paths.remove(0)}));
}
let skipped = paths.len().saturating_sub(MAX_FONTS_PER_PICK);
paths.truncate(MAX_FONTS_PER_PICK);
let (mut added, mut failed) = (0usize, Vec::new());
for path in paths {
match self.run("font.add", json!({"path": path})) {
Ok(_) => added += 1,
Err(e) => {
log::warn!("couldn't add font {path}: {e}");
let name = std::path::Path::new(&path).file_name().map_or(path.clone(), |n| n.to_string_lossy().into_owned());
failed.push(name);
}
}
}
let n = added.to_string();
let mut msg = if added == 1 { tl!("Added 1 font").to_string() } else { crate::i18n::fmt(tl!("Added {n} fonts"), &[("n", &n)]) };
if !failed.is_empty() {
let count = failed.len().to_string();
let mut names = failed.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
if failed.len() > 3 {
let more = (failed.len() - 3).to_string();
names = crate::i18n::fmt(tl!("{names} and {n} more"), &[("names", &names), ("n", &more)]);
}
msg = crate::i18n::fmt(tl!("{added}; {count} couldn't be read: {names}"), &[("added", &msg), ("count", &count), ("names", &names)]);
}
if skipped > 0 {
let (n, max) = (skipped.to_string(), MAX_FONTS_PER_PICK.to_string());
msg = crate::i18n::fmt(tl!("{added}; {n} skipped (limit {max})"), &[("added", &msg), ("n", &n), ("max", &max)]);
}
self.status(msg);
Ok(json!({"added": added, "failed": failed, "skipped": skipped}))
}
/// Do what the dialog was for with the picked path (`None`: the user cancelled).
pub(crate) fn finish_pick(&mut self, after: AfterPick, picked: Option<String>) -> Result<Value, String> {
match after {
AfterPick::OpenDocument => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
let r = self.run("file.open", json!({"path": path}));
self.ui.backstage = false;
r
}
AfterPick::Picture => {
let Some(path) = picked else {
self.change_picture_target = None;
return Ok(json!({"cancelled": true}));
};
self.insert_or_change_picture(json!({"path": path}))
}
AfterPick::Font => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
self.add_fonts(vec![path])
}
#[cfg(not(target_arch = "wasm32"))]
AfterPick::Sources => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
let before = self.session.doc.sources.len();
let r = self.run("references.sources", json!({"import": path}));
if r.is_ok() {
let added = self.session.doc.sources.len().saturating_sub(before);
let msg = match added {
0 => tl!("No new sources in that file").to_string(),
1 => tl!("Imported 1 source").to_string(),
n => crate::i18n::fmt(tl!("Imported {n} sources"), &[("n", &n.to_string())]),
};
self.status(msg);
}
r
}
AfterPick::ForDialog => {
let picked = picked.filter(|p| !p.is_empty());
let r = json!({"picked": picked});
self.dialog_pick = picked;
Ok(r)
}
AfterPick::Recipients => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
self.load_recipients(json!({"path": path}))
}
AfterPick::ComparePath { revised } => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
crate::dialogs_review::set_compare_path(self, revised, path);
Ok(json!({"picked": true}))
}
AfterPick::Export { ext } => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
let r = if ext == "png" {
self.run("file.exportPng", json!({"path": path}))
} else if ext == crate::print_page::EXPORT_WORD_2003 {
self.run("file.saveAs", json!({"path": path, "format": ext}))
} else {
self.run("file.saveAs", json!({"path": path}))
};
if r.is_ok() {
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
}
r
}
AfterPick::PrintPdf { settings } => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
let mut params = settings;
params["path"] = json!(path);
let r = self.run("file.printPdf", params);
if r.is_ok() {
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
}
r
}
AfterPick::SaveAs { after, document } => {
if document != self.session.document_id() {
return Err("the document for this save has been replaced".into());
}
// The save's error, kept for the Save Changes prompt to explain (#1445). A plain
// Save As (Backstage's Browse…, AutoSave) has no prompt to carry it to, so it was
// otherwise dropped: a failed write (no permission, disk full, a bad path) left the
// Save As page sitting there with nothing to say why, since its own render loop
// skips the status bar while Backstage is open. `fail_save_as` reaches both.
let result = picked.map(|path| self.run("file.save", json!({"path": path})));
let saved = matches!(&result, Some(Ok(v)) if v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some());
if saved {
self.ui.backstage = false;
self.canvas.want_focus = true;
self.clear_save_as_error();
}
match after {
AfterSave::Nothing => {
if let Some(Err(error)) = &result {
self.fail_save_as(error);
}
Ok(json!({"saved": saved}))
}
AfterSave::AutoSave => {
if saved {
self.autosave = true;
} else if let Some(Err(error)) = &result {
self.fail_save_as(error);
}
Ok(json!({"saved": saved}))
}
AfterSave::Continue { then, params, document } => {
if let Some(Err(error)) = result {
return Err(self.save_failed(&error, then, params, document));
}
if !self.safely_saved(saved) || document != self.session.document_id() {
return Ok(json!({"done": false}));
}
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
}
}
}
}
}
/// Ask where to save, then save there (File › Save As).
pub fn save_as_dialog(&mut self) {
let _ = self.save_as(AfterSave::Nothing);
}
/// A plain Save As (Backstage's Browse…, AutoSave) failed to write: say so on the status bar,
/// and, if the Save As page is what's showing, there too — its own render loop skips the
/// status bar while Backstage is open, so that page was otherwise left with nothing to explain
/// a failed write (no permission, disk full, a bad path).
fn fail_save_as(&mut self, error: &str) {
self.status(error.to_string());
if let Some(ctx) = self.ctx.clone() {
ctx.data_mut(|d| d.insert_temp(crate::backstage::save_as_error_id(), error.to_string()));
}
}
/// Drop a Save As page error once a later attempt has actually saved.
fn clear_save_as_error(&mut self) {
if let Some(ctx) = self.ctx.clone() {
crate::backstage::forget_save_as_error(&ctx);
}
}
/// Ask where to save, save there, then do `after`.
pub(crate) fn save_as(&mut self, after: AfterSave) -> Asked {
let name = self.default_save_name();
self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::SaveAs { after, document: self.session.document_id() })
}
/// The user templates folder (#2852): the one File › Options names, else the app's own.
/// `None` on the web, which has no folders.
pub fn user_templates_dir(&self) -> Option<std::path::PathBuf> {
// The saved folder is checked again (the settings file may have been edited, the folder
// removed): a network folder is never looked at, and one that's gone gives way to the
// app's own.
let saved = self.session.prefs.user_templates_folder.as_deref().map(str::trim).filter(|f| !f.is_empty());
match saved {
Some(f) if wordcraft_engine::cmd::user_templates::check_dir(f).is_ok() && std::path::Path::new(f).is_dir() => {
Some(std::path::PathBuf::from(f))
}
Some(f) => {
log::debug!("user templates folder {f:?} isn't usable; using the app's own");
self.services.templates_dir.clone()
}
None => self.services.templates_dir.clone(),
}
}
/// File › Save As › Save as Word Template (#2852): ask where to save a `.dotx`, starting in
/// the user templates folder (the app's own is made on first use), then save there as Save As
/// does. Returns the folder the dialog starts in.
pub(crate) fn save_as_template_dialog(&mut self) -> Option<String> {
let dir = self.user_templates_dir();
#[cfg(not(target_arch = "wasm32"))]
if let Some(d) = &dir
&& !d.is_dir()
&& let Err(e) = std::fs::create_dir_all(d)
{
log::warn!("couldn't make the templates folder {}: {e}", d.display());
}
let dir = dir.filter(|d| d.is_dir()).map(|d| d.to_string_lossy().into_owned());
let name = format!("{}.dotx", self.title_stem());
let after = AfterPick::SaveAs { after: AfterSave::Nothing, document: self.session.document_id() };
let _ = self.ask_file(FileDialogRequest::Save { name, dir: dir.clone() }, after);
dir
}
/// File › Export: ask where to save a copy in this format, then write it.
pub(crate) fn export_dialog(&mut self, ext: &str) {
let file_ext = if ext == crate::print_page::EXPORT_WORD_2003 { "xml" } else { ext };
let name = format!("{}.{file_ext}", self.title_stem());
let _ = self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::Export { ext: ext.to_string() });
}
/// File › Print › Save these pages as PDF: ask where, then write the pages the print
/// settings choose.
pub(crate) fn print_pdf_dialog(&mut self) {
let name = format!("{}.pdf", self.title_stem());
let settings = self.ui.print.params();
let _ = self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::PrintPdf { settings });
}
pub(crate) fn open_dialog(&mut self) {
if let Some(f) = &self.services.open_async {
f("document");
return;
}
let _ = self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument);
}
pub(crate) fn pick_picture(&mut self) {
if let Some(f) = &self.services.open_async {
f("picture");
return;
}
let _ = self.ask_file(FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture);
}
/// Pick a font file to add (Add Font…; web: it arrives through the inbox).
pub(crate) fn pick_font(&mut self) {
if let Some(f) = &self.services.open_async {
f("font");
return;
}
let _ = self.ask_file(FileDialogRequest::OpenMany { purpose: "font".into() }, AfterPick::Font);
}
/// Request loading local system installed fonts (web: queryLocalFonts).
pub(crate) fn load_system_fonts(&mut self) {
if let Some(f) = &self.services.load_system_fonts {
f();
} else {
self.status(tl!("All system fonts are already available."));
}
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::mpsc::{Sender, channel};
use super::*;
use crate::Services;
use wordcraft_engine::Session;
const UNSAVED: &str = "My unsaved novel chapter";
/// Original Word 97 compound file: one compressed text piece, built from MS-DOC's FIB/Clx.
fn legacy_word_bytes() -> Vec<u8> {
use std::io::{Cursor, Write};
let text = b"Legacy contract\r";
let mut word = vec![0u8; 0x600];
for (at, value) in [(0, 0xA5ECu16), (2, 0x00C1), (0x0A, 0x1000), (0x0C, 0x00BF), (0x20, 14), (0x3E, 22), (0x98, 0x005D)] {
word[at..at + 2].copy_from_slice(&value.to_le_bytes());
}
word[0x40..0x44].copy_from_slice(&0x1000u32.to_le_bytes());
word[0x4C..0x50].copy_from_slice(&(text.len() as u32).to_le_bytes());
let mut clx = vec![2];
clx.extend_from_slice(&16u32.to_le_bytes());
clx.extend_from_slice(&0u32.to_le_bytes());
clx.extend_from_slice(&(text.len() as u32).to_le_bytes());
clx.extend_from_slice(&[0, 0]);
clx.extend_from_slice(&(0x4000_0000u32 | (2 * 0x600)).to_le_bytes());
clx.extend_from_slice(&[0, 0]);
let pair = 0x9A + 33 * 8;
word[pair..pair + 4].copy_from_slice(&0x40u32.to_le_bytes());
word[pair + 4..pair + 8].copy_from_slice(&(clx.len() as u32).to_le_bytes());
word.extend_from_slice(text);
let mut table = vec![0; 0x40];
table.extend_from_slice(&clx);
let mut file = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
for (name, data) in [("/WordDocument", word), ("/0Table", table)] {
file.create_stream(name).unwrap().write_all(&data).unwrap();
}
file.into_inner().into_inner()
}
/// #2445: Save on an imported binary Word file asks for DOCX, preserving the import on Cancel.
#[test]
fn imported_word_save_asks_for_docx_and_preserves_the_original() {
for ext in ["doc", "DOT", "DOC", "dot"] {
let dir = scratch(&format!("imported-{ext}"));
let original = dir.join(format!("contract.{ext}"));
let bytes = legacy_word_bytes();
std::fs::write(&original, &bytes).unwrap();
let (mut a, shown) = async_app();
a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap();
assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Legacy contract"));
a.run("text.insert", json!({"text": "Revised "})).unwrap();
let draft = a.session.doc.clone();
let revision = a.session.rev();
let result = a.run("file.save", json!({})).unwrap();
assert_eq!(result["pending"], "saveAs");
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None });
answer(&shown, None);
assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], false);
assert_eq!(a.session.doc, draft);
assert_eq!(a.session.rev(), revision);
assert!(a.session.dirty);
assert_eq!(a.session.path.as_deref(), Some(original.as_path()));
assert_eq!(std::fs::read(&original).unwrap(), bytes);
a.run("file.save", json!({})).unwrap();
let saved = dir.join("revised.docx");
answer(&shown, Some(&saved.to_string_lossy()));
assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], true);
assert!(!a.session.dirty);
assert_eq!(a.session.path.as_deref(), Some(saved.as_path()));
assert!(saved_text(&saved).contains("Revised Legacy contract"));
assert_eq!(std::fs::read(&original).unwrap(), bytes);
// The new supported path saves directly, without another chooser.
a.run("text.insert", json!({"text": "Again "})).unwrap();
assert_eq!(a.run("file.save", json!({})).unwrap()["saved"], true);
assert!(shown.borrow().is_empty());
assert!(saved_text(&saved).contains("Again "));
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn imported_word_close_waits_for_save_as_and_keeps_failed_or_cancelled_drafts() {
let dir = scratch("imported-close");
let original = dir.join("contract.doc");
let bytes = legacy_word_bytes();
std::fs::write(&original, &bytes).unwrap();
let (mut a, shown) = async_app();
a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap();
a.run("text.insert", json!({"text": "Revised "})).unwrap();
let draft = a.session.doc.clone();
a.run("file.close", json!({})).unwrap();
assert_eq!(a.run("ui.saveChanges", json!({"answer": "save"})).unwrap()["pending"], "saveAs");
assert!(a.has_document() && !a.quit_requested);
answer(&shown, Some(&dir.join("missing").join("contract.docx").to_string_lossy()));
let error = a.poll_file_dialog().unwrap().unwrap_err();
assert!(error.contains("still open"), "{error}");
assert!(matches!(a.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })));
assert!(a.has_document() && a.session.dirty && !a.quit_requested);
assert_eq!(a.session.path.as_deref(), Some(original.as_path()));
assert_eq!(std::fs::read(&original).unwrap(), bytes);
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), draft.plain_text(wordcraft_doc::StoryRef::Body));
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
answer(&shown, None);
assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], false);
assert!(a.has_document() && a.session.dirty && !a.quit_requested);
assert_eq!(a.session.path.as_deref(), Some(original.as_path()));
a.run("file.close", json!({})).unwrap();
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
let saved = dir.join("contract.docx");
answer(&shown, Some(&saved.to_string_lossy()));
assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], true);
// File › Close keeps the window (#1712): the document closes once the DOCX save succeeds.
assert!(!a.has_document() && !a.quit_requested, "Close runs only after the DOCX save succeeds");
assert!(saved_text(&saved).contains("Revised Legacy contract"));
assert_eq!(std::fs::read(&original).unwrap(), bytes);
let _ = std::fs::remove_dir_all(dir);
}
/// A late Save As answer belongs to the imported document that opened the picker.
#[test]
fn imported_word_stale_save_as_never_writes_the_replacement_document() {
for close in [false, true] {
let dir = scratch(if close { "imported-stale-close" } else { "imported-stale-save" });
let original = dir.join("contract.doc");
let bytes = legacy_word_bytes();
std::fs::write(&original, &bytes).unwrap();
let destination = dir.join("existing.docx");
let sentinel = b"Existing destination must survive";
std::fs::write(&destination, sentinel).unwrap();
let (mut a, shown) = async_app();
a.run("file.open", json!({"path": original})).unwrap();
typed(&mut a);
if close {
a.run("file.close", json!({})).unwrap();
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
} else {
a.run("file.save", json!({})).unwrap();
}
assert!(a.file_dialog_open());
let previous = a.session.document_id();
// Automation bypasses the user prompt and can replace a document during a picker.
a.execute("file.new", json!({})).unwrap();
typed(&mut a);
assert_ne!(previous, a.session.document_id());
let draft = a.session.doc.clone();
let revision = a.session.rev();
answer(&shown, Some(&destination.to_string_lossy()));
let _ = a.poll_file_dialog().unwrap();
assert!(std::fs::read(&destination).unwrap() == sentinel, "stale picker overwrote the destination");
assert_eq!(std::fs::read(&original).unwrap(), bytes);
assert_eq!(a.session.doc, draft);
assert_eq!(a.session.rev(), revision);
assert!(a.session.dirty && a.session.path.is_none() && !a.quit_requested);
}
}
#[test]
fn imported_word_programmatic_and_explicit_saves_never_open_dialogs() {
let dir = scratch("imported-explicit");
let original = dir.join("contract.doc");
let bytes = legacy_word_bytes();
std::fs::write(&original, &bytes).unwrap();
let (mut a, shown) = async_app();
a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap();
a.run("text.insert", json!({"text": "Revised "})).unwrap();
let error = a.execute("file.save", json!({})).unwrap_err();
assert!(error.contains("can't save as `.doc`"), "{error}");
let error = a.run("file.save", json!({"path": original.to_string_lossy()})).unwrap_err();
assert!(error.contains("can't save as `.doc`"), "{error}");
assert!(shown.borrow().is_empty());
assert!(a.session.dirty);
assert_eq!(a.session.path.as_deref(), Some(original.as_path()));
assert_eq!(std::fs::read(&original).unwrap(), bytes);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn imported_word_save_controls_ask_for_docx() {
use egui_kittest::kittest::Queryable;
let dir = scratch("imported-entry-points");
let original = dir.join("contract.doc");
let bytes = legacy_word_bytes();
std::fs::write(&original, &bytes).unwrap();
let (mut a, shown) = async_app();
a.run("ui.language", json!({"value": "en"})).unwrap();
a.run("file.open", json!({"path": original})).unwrap();
typed(&mut a);
a.run("ui.backstage", json!({"value": true, "page": "home"})).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,
);
h.run_steps(4);
h.get_by_label("Save").click();
h.run_steps(3);
assert!(h.state().file_dialog_open(), "the Save button asks for a supported target");
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None });
answer(&shown, None);
h.run_steps(3);
h.state_mut().run("ui.backstage", json!({"value": false})).unwrap();
h.state_mut().canvas.want_focus = true;
h.run_steps(6);
assert!(h.state().canvas.focused, "Mod+S starts in the editor");
h.event(egui::Event::Key { key: egui::Key::S, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
h.run_steps(3);
assert!(h.state().file_dialog_open(), "the focused editor's Mod+S asks too");
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None });
answer(&shown, None);
h.run_steps(3);
assert!(h.state().session.dirty);
assert_eq!(h.state().session.path.as_deref(), Some(original.as_path()));
assert_eq!(std::fs::read(&original).unwrap(), bytes);
}
/// Dialogs the fake host was asked to show, each with the channel its answer goes through.
type Shown = Rc<RefCell<Vec<(FileDialogRequest, Sender<FileDialogAnswer>)>>>;
/// An app whose host shows dialogs asynchronously: nothing is answered until the test does.
fn async_app() -> (WordApp, Shown) {
let shown: Shown = Rc::default();
let log = shown.clone();
let services = Services {
file_dialog: Some(Box::new(move |req| {
let (tx, rx) = channel();
log.borrow_mut().push((req, tx));
rx
})),
..Default::default()
};
(WordApp::new(Session::new(wordcraft_doc::Document::new()), services), shown)
}
/// Answer the dialog the host is showing (the last one asked for).
fn answer(shown: &Shown, picked: Option<&str>) {
answer_all(shown, picked.as_slice());
}
/// Answer the dialog with several picked paths (none: cancelled).
fn answer_all(shown: &Shown, picked: &[&str]) {
let (_, tx) = shown.borrow_mut().pop().unwrap();
tx.send(Ok(picked.iter().map(|p| p.to_string()).collect())).unwrap();
}
fn typed(a: &mut WordApp) {
a.run("text.insert", json!({"text": UNSAVED})).unwrap();
assert!(a.session.dirty);
}
fn body_text(a: &WordApp) -> String {
a.session.doc.plain_text(wordcraft_doc::StoryRef::Body)
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("wordcraft-ui-dialogs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn saved_text(path: &std::path::Path) -> String {
wordcraft_engine::io::open_path(path).unwrap().plain_text(wordcraft_doc::StoryRef::Body)
}
/// Save as Word Template (#2852) suggests a .dotx in the user templates folder, which is made
/// on first use; the picked path is saved as a template. A folder named in File › Options
/// replaces the app's own.
#[test]
fn save_as_template_starts_in_the_templates_folder() {
let dir = scratch("save-template");
let own = dir.join("Templates");
let (mut a, shown) = async_app();
a.services.templates_dir = Some(own.clone());
typed(&mut a);
let r = a.run("ui.saveAsTemplate", json!({})).unwrap();
assert!(own.is_dir(), "the app's templates folder is made on first use");
assert_eq!(r["dir"], json!(own.to_string_lossy()));
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.dotx".into(), dir: Some(own.to_string_lossy().into_owned()) });
let path = own.join("Memo.dotx");
answer(&shown, Some(&path.to_string_lossy()));
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas();
assert!(path.is_file(), "saved as a template");
assert_eq!(saved_text(&path), UNSAVED);
let listed = wordcraft_engine::cmd::user_templates::list_templates(&own.to_string_lossy()).unwrap();
assert_eq!(listed.templates.iter().map(|t| t.label.as_str()).collect::<Vec<_>>(), ["Memo"]);
// A folder chosen in Options wins.
let chosen = dir.join("Chosen");
std::fs::create_dir_all(&chosen).unwrap();
a.run("file.options", json!({"userTemplatesFolder": chosen.to_string_lossy()})).unwrap();
assert_eq!(a.user_templates_dir(), Some(chosen.clone()));
a.run("ui.saveAsTemplate", json!({})).unwrap();
assert!(
matches!(&shown.borrow()[0].0, FileDialogRequest::Save { name, dir: Some(d) } if name == "Memo.dotx" && *d == chosen.to_string_lossy())
);
answer(&shown, None);
let _ = std::fs::remove_dir_all(&dir);
}
/// Save As returns at once (the UI keeps running); the frame loop saves once the answer arrives.
#[test]
fn save_as_answer_arriving_later_is_saved() {
let dir = scratch("later");
let path = dir.join("novel.docx");
let (mut a, shown) = async_app();
typed(&mut a);
a.save_as_dialog();
assert!(a.file_dialog_open());
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into(), dir: None });
let ctx = egui::Context::default();
let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas();
frame(&mut a);
assert!(a.session.dirty, "nothing saved before the user answers");
answer(&shown, Some(&path.to_string_lossy()));
frame(&mut a);
assert!(!a.file_dialog_open());
assert!(!a.session.dirty);
assert!(saved_text(&path).contains(UNSAVED));
let _ = std::fs::remove_dir_all(&dir);
}
/// #689: Save As honours the filename typed in the dialog, not just the folder: a document
/// opened as `a/one.docx` and saved as `b/two.docx` is written there and takes that path.
#[test]
fn save_as_with_a_new_filename_saves_under_it() {
let dir = scratch("rename");
let opened = dir.join("one.docx");
let picked = dir.join("sub").join("two.docx");
std::fs::create_dir_all(dir.join("sub")).unwrap();
wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("original"))
.run("file.save", &json!({"path": opened.to_string_lossy()}))
.unwrap();
let (mut a, shown) = async_app();
a.run("file.open", json!({"path": opened.to_string_lossy()})).unwrap();
assert_eq!(a.session.path.as_deref(), Some(opened.as_path()));
a.save_as_dialog();
answer(&shown, Some(&picked.to_string_lossy()));
let r = a.poll_file_dialog().unwrap().unwrap();
assert_eq!(r, json!({"saved": true}), "the save-as succeeded");
assert_eq!(a.session.path.as_deref(), Some(picked.as_path()), "the picked filename is the document's path");
assert!(picked.is_file(), "the file is written under the picked name");
assert!(saved_text(&picked).contains("original"));
// F12 (file.saveAs without a path) lands in the same dialog, suggesting the new name.
let r = a.run("file.saveAs", json!({})).unwrap();
assert_eq!(r, json!({"saved": false}));
assert!(a.file_dialog_open(), "F12's saveAs request opens the dialog");
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "two.docx".into(), dir: None });
let _ = std::fs::remove_dir_all(&dir);
}
/// Backstage File > Save > Save As page > Browse… picks a folder that can't be written to
/// (missing, no permission...): the write fails, but the page previously said nothing about
/// it, leaving the user with a Browse… button and no document, no clue why. The failure now
/// reaches the status bar, and the document (and its unsaved text) stays open regardless.
#[test]
fn a_failed_save_as_says_so_and_keeps_the_document() {
let dir = scratch("write-fails");
let bogus = dir.join("does-not-exist").join("out.docx");
let (mut a, shown) = async_app();
typed(&mut a);
a.run("ui.backstage", json!({"value": true, "page": "saveAs"})).unwrap();
a.save_as_dialog();
answer(&shown, Some(&bogus.to_string_lossy()));
let r = a.poll_file_dialog().unwrap().unwrap();
assert_eq!(r["saved"], false, "the write failed");
assert!(!bogus.exists());
assert!(a.ui.backstage, "backstage stays open: nothing was actually saved");
assert!(a.has_document() && body_text(&a).contains(UNSAVED), "the document and its text are kept");
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert!(!msg.is_empty(), "the failure is no longer silent");
let _ = std::fs::remove_dir_all(&dir);
}
/// The AutoSave switch's own Save As (clicked while blocked on `Unsaved`, chrome.rs) goes
/// through the same `AfterPick::SaveAs` arm as Backstage's, under `AfterSave::AutoSave`
/// instead of `AfterSave::Nothing`: a write failure there must reach the status bar too
/// (visible here, since the switch only shows while Backstage is closed), and AutoSave must
/// stay off rather than claim coverage it doesn't have (the raw `autosave` flag defaults to
/// `true`; what actually governs coverage is `autosaves()`, the flag plus the block).
#[test]
fn a_failed_autosave_switch_save_as_says_so_and_leaves_autosave_off() {
let dir = scratch("autosave-switch-write-fails");
let bogus = dir.join("does-not-exist").join("out.docx");
let (mut a, shown) = async_app();
typed(&mut a);
assert!(matches!(a.autosave_block(), Some(crate::AutoSaveBlock::Unsaved)), "a new document blocks AutoSave until saved");
assert!(!a.autosaves(), "blocked: AutoSave isn't covering the document yet");
a.save_as(AfterSave::AutoSave);
answer(&shown, Some(&bogus.to_string_lossy()));
let r = a.poll_file_dialog().unwrap().unwrap();
assert_eq!(r["saved"], false, "the write failed");
assert!(!a.autosaves(), "still blocked: the save that would have lifted it never happened");
assert!(a.session.path.is_none(), "unsaved: the write never actually landed");
assert!(a.has_document() && body_text(&a).contains(UNSAVED), "the document and its text are kept");
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert!(!msg.is_empty(), "the failure is no longer silent");
let _ = std::fs::remove_dir_all(&dir);
}
/// While a dialog is open, more Save As / Open / Export / Picture clicks don't open a second one.
#[test]
fn a_second_dialog_while_one_is_open_is_ignored() {
let (mut a, shown) = async_app();
a.save_as_dialog();
a.save_as_dialog();
a.open_dialog();
a.export_dialog("pdf");
a.pick_picture();
assert_eq!(shown.borrow().len(), 1, "only the first dialog was shown");
answer(&shown, None);
a.poll_file_dialog();
assert!(!a.file_dialog_open());
a.open_dialog();
assert_eq!(shown.borrow().len(), 1, "a new dialog once the first was answered");
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() });
}
/// #2313: Export PDF from the command search (no path given) asks where to save it, as the
/// Backstage's Export button does, instead of failing with "bad parameters: `path` (string)
/// is required" on the status bar.
#[test]
fn export_pdf_without_a_path_asks_where_to_save() {
let (mut a, shown) = async_app();
let r = a.run("file.exportPdf", json!({})).unwrap();
assert_eq!(r, json!({"exported": false}));
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{}.pdf", a.title_stem()), dir: None });
let dir = scratch("export-pdf");
let picked = dir.join("out.pdf");
answer(&shown, Some(&picked.to_string_lossy()));
let r = a.poll_file_dialog().unwrap().unwrap();
assert_eq!(r["path"], json!(picked.to_string_lossy().to_string()));
let bytes = std::fs::read(&picked).unwrap();
assert!(bytes.starts_with(b"%PDF"), "a PDF is written to the picked path");
let _ = std::fs::remove_dir_all(&dir);
}
/// Open, Save As and Export PNG from the command search (no path) ask for a file the same way.
#[test]
fn file_commands_without_a_path_ask_for_one() {
let (mut a, shown) = async_app();
a.run("file.open", json!({})).unwrap();
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() });
// A second dialog can't open while one is up, so cancel before the next command.
answer(&shown, None);
a.poll_file_dialog();
a.run("file.saveAs", json!({})).unwrap();
assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { .. }));
answer(&shown, None);
a.poll_file_dialog();
a.run("file.exportPng", json!({})).unwrap();
assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { ref name, .. } if name.ends_with(".png")));
}
/// #1445: "Save changes?" → Save on a new document while another file dialog is still open:
/// nothing can be asked, so nothing happens, and the status bar says why.
#[test]
fn save_changes_while_a_file_dialog_is_open_says_so() {
let (mut a, shown) = async_app();
typed(&mut a);
a.open_dialog();
assert_eq!(shown.borrow().len(), 1);
a.run("file.close", json!({})).unwrap();
a.status_msg = None;
let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(r["done"], false);
assert!(!a.quit_requested);
assert!(a.session.dirty && body_text(&a).contains(UNSAVED));
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert!(msg.contains("file dialog is already open"), "the status bar says why: {msg:?}");
}
/// "Save changes?" → Save on a new document: the held-back Close runs only once the later
/// Save As has actually saved; a cancelled Save As cancels the held-back New.
#[test]
fn save_changes_waits_for_the_save_as_answer() {
let dir = scratch("prompt");
let path = dir.join("novel.docx");
let (mut a, shown) = async_app();
typed(&mut a);
a.run("file.new", json!({})).unwrap();
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
answer(&shown, None);
a.poll_file_dialog();
assert!(body_text(&a).contains(UNSAVED), "cancelled Save As keeps the document");
assert!(a.session.dirty);
a.run("file.close", json!({})).unwrap();
let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(r["pending"], "saveAs");
a.poll_file_dialog();
assert!(a.has_document(), "nothing closes before the save");
answer(&shown, Some(&path.to_string_lossy()));
a.poll_file_dialog();
assert!(!a.has_document(), "closed once saved");
assert!(saved_text(&path).contains(UNSAVED));
let _ = std::fs::remove_dir_all(&dir);
}
/// Desktop (#240 on #94): Use an Existing List… asks the host's file dialog for a recipient
/// list and, once the answer arrives, loads it as the mail-merge recipients, not as a document.
#[test]
fn a_recipient_list_picked_later_loads_recipients() {
let dir = scratch("recipients");
let csv = dir.join("people.csv");
std::fs::write(&csv, "First Name,City\nAda,London\nAlan,Wilmslow\n").unwrap();
let (mut a, shown) = async_app();
typed(&mut a);
a.run("ui.openRecipientList", json!({})).unwrap();
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "recipients".into() });
assert!(a.file_dialog_open());
answer(&shown, Some(&csv.to_string_lossy()));
a.poll_file_dialog().unwrap().unwrap();
assert_eq!(a.session.merge.headers, vec!["First Name", "City"]);
assert_eq!(a.session.merge.rows.len(), 2);
assert!(body_text(&a).contains(UNSAVED), "the document stays open");
// Cancelling loads nothing.
a.run("ui.openRecipientList", json!({})).unwrap();
answer(&shown, None);
assert_eq!(a.poll_file_dialog().unwrap().unwrap(), json!({"cancelled": true}));
assert_eq!(a.session.merge.rows.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
/// #2640 (desktop): Manage Sources › Import… hands the picked Word bibliography file
/// (`Sources.xml`, `b:Sources`) to `references.sources`, and the status says how many
/// sources joined the document. Cancelling imports nothing, and importing the same file
/// twice adds nothing the second time.
#[test]
fn an_imported_sources_file_adds_its_sources() {
let dir = scratch("sources");
let xml = dir.join("Sources.xml");
std::fs::write(
&xml,
r#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography">
<b:Source><b:Tag>Riv21</b:Tag><b:SourceType>Book</b:SourceType><b:Title>Shared Spaces</b:Title><b:Year>2021</b:Year></b:Source>
</b:Sources>"#,
)
.unwrap();
let (mut a, _) = async_app();
a.finish_pick(AfterPick::Sources, Some(xml.to_string_lossy().to_string())).unwrap();
assert_eq!(a.session.doc.sources.len(), 1);
assert_eq!(a.session.doc.sources[0].tag, "Riv21");
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("Imported 1 source")), "{:?}", a.status_msg);
assert_eq!(a.finish_pick(AfterPick::Sources, None).unwrap(), json!({"cancelled": true}));
a.finish_pick(AfterPick::Sources, Some(xml.to_string_lossy().to_string())).unwrap();
assert_eq!(a.session.doc.sources.len(), 1, "the tag is already here");
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m == "No new sources in that file"), "{:?}", a.status_msg);
let _ = std::fs::remove_dir_all(&dir);
}
/// A workbook with several sheets (#334) shows Select Table; its answer loads that sheet.
#[test]
fn a_workbook_with_several_sheets_asks_which_one() {
use std::io::Write;
let dir = scratch("workbook");
let book = dir.join("people.ods");
let table = |name: &str, cells: &[&str]| {
let cells: String = cells
.iter()
.map(|c| {
format!(
r#"<table:table-row><table:table-cell office:value-type="string"><text:p>{c}</text:p></table:table-cell></table:table-row>"#
)
})
.collect();
format!(r#"<table:table table:name="{name}">{cells}</table:table>"#)
};
let content = format!(
r#"<office:document-content xmlns:office="o" xmlns:table="t" xmlns:text="x"><office:body><office:spreadsheet>{}{}</office:spreadsheet></office:body></office:document-content>"#,
table("Notes", &["n"]),
table("Guests", &["Name", "Ada", "Alan"])
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in [("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content.as_str())] {
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(body.as_bytes()).unwrap();
}
std::fs::write(&book, z.finish().unwrap().into_inner()).unwrap();
let (mut a, shown) = async_app();
a.run("ui.openRecipientList", json!({})).unwrap();
answer(&shown, Some(&book.to_string_lossy()));
a.poll_file_dialog().unwrap().unwrap();
let Some(crate::dialogs::Dialog::SelectTable { sheets, params, headers: true, .. }) = &a.dialog else { panic!("Select Table expected") };
assert_eq!(sheets, &["Notes", "Guests"]);
assert!(a.session.merge.headers.is_empty(), "nothing loads before a sheet is chosen");
let mut p = params.clone();
p["sheet"] = json!(2);
a.load_recipients(p).unwrap();
assert_eq!(a.session.merge.headers, vec!["Name"]);
assert_eq!(a.session.merge.rows.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_system_fonts_runs_hook_when_present() {
use std::cell::Cell;
let called = Rc::new(Cell::new(false));
let called2 = called.clone();
let services = Services { load_system_fonts: Some(Box::new(move || called2.set(true))), ..Default::default() };
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
a.run("font.loadSystem", json!({})).unwrap();
assert!(called.get());
}
#[test]
fn load_system_fonts_without_hook_reports_status() {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
a.run("font.loadSystem", json!({})).unwrap();
assert_eq!(a.status_msg.as_ref().map(|(s, _)| s.as_str()), Some("All system fonts are already available."));
}
/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it
/// is a font the database doesn't have.
fn renamed_font(file: &str, original: &str, family: &str) -> Vec<u8> {
assert_eq!(family.len(), original.len());
let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] {
let mut i = 0;
while let Some(at) = data.get(i..).and_then(|d| d.windows(from.len()).position(|w| w == from)) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
i += at + to.len();
}
}
data
}
fn font_families(a: &mut WordApp) -> Vec<String> {
let list = a.run("font.list", json!({"all": true})).unwrap();
list["families"].as_array().unwrap().iter().filter_map(|f| f.as_str().map(str::to_string)).collect()
}
/// #1789: Add Font asks for several files, and every one picked is added; an unreadable file
/// is reported and the rest still go in.
#[test]
fn add_font_imports_every_selected_file() {
const ALPHA: &str = "Mfpick Alpha 1";
const BRAVO: &str = "Mfpick Bravo 2";
let dir = scratch("fonts");
let alpha = dir.join("MfpickAlpha1-Regular.ttf");
let junk = dir.join("Junk.ttf");
let bravo = dir.join("MfpickBravo2-Regular.ttf");
std::fs::write(&alpha, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", ALPHA)).unwrap();
std::fs::write(&junk, b"this is not a font").unwrap();
std::fs::write(&bravo, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", BRAVO)).unwrap();
let (mut a, shown) = async_app();
a.pick_font();
assert_eq!(shown.borrow()[0].0, FileDialogRequest::OpenMany { purpose: "font".into() }, "Add Font is multi-select");
let paths: Vec<String> = [&alpha, &junk, &bravo].iter().map(|p| p.to_string_lossy().into_owned()).collect();
answer_all(&shown, &paths.iter().map(String::as_str).collect::<Vec<_>>());
let r = a.poll_file_dialog().unwrap().unwrap();
assert_eq!(r["added"], 2);
assert_eq!(r["failed"].as_array().map(Vec::len), Some(1));
let families = font_families(&mut a);
assert!(families.iter().any(|f| f == ALPHA) && families.iter().any(|f| f == BRAVO), "both valid fonts were added");
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert_eq!(msg, "Added 2 fonts; 1 couldn't be read: Junk.ttf");
for f in [ALPHA, BRAVO] {
a.run("font.remove", json!({"family": f})).unwrap();
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Only Add Font is multi-select; a cancelled pick adds nothing.
#[test]
fn only_add_font_asks_for_several_files() {
let (mut a, shown) = async_app();
a.open_dialog();
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() });
answer(&shown, None);
a.poll_file_dialog();
a.pick_picture();
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "picture".into() });
answer(&shown, None);
a.poll_file_dialog();
a.pick_font();
assert!(matches!(shown.borrow()[0].0, FileDialogRequest::OpenMany { .. }));
answer_all(&shown, &[]);
assert_eq!(a.poll_file_dialog().unwrap().unwrap(), json!({"cancelled": true}));
assert!(!a.file_dialog_open());
}
/// A hostile selection is bounded: only the first MAX_FONTS_PER_PICK are tried.
#[test]
fn a_huge_font_selection_is_capped() {
let (mut a, _) = async_app();
let paths: Vec<String> = (0..MAX_FONTS_PER_PICK + 50).map(|i| format!("/nonexistent/f{i}.ttf")).collect();
let r = a.add_fonts(paths).unwrap();
assert_eq!(r["added"], 0);
assert_eq!(r["failed"].as_array().map(Vec::len), Some(MAX_FONTS_PER_PICK));
assert_eq!(r["skipped"], 50);
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert_eq!(msg, "Added 0 fonts; 2000 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 1997 more; 50 skipped (limit 2000)");
}
/// Past three failures the status line names three and counts the rest.
#[test]
fn many_failed_fonts_end_with_a_count() {
let (mut a, _) = async_app();
let paths: Vec<String> = (0..5).map(|i| format!("/nonexistent/f{i}.ttf")).collect();
a.add_fonts(paths).unwrap();
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert_eq!(msg, "Added 0 fonts; 5 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 2 more");
}
}