mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
Show an alert when a file can't be opened or placed (#80)
* Opening an InDesign document says to export IDML (#21) .indd and .indt files (recognised by their published 16-byte signature) failed to open or place with a confusing format error. They now explain that the format is closed and how to export IDML from InDesign instead. File › Open offers .indd and .indt files (desktop and web), and dropping one on the window opens it like a .designcraft or .idml file. A file that can't be opened or placed from File › Open, Place, a drop, the command line or the control channel's app.open shows an alert with the reason, as well as the status line. * File open: an .indd file is an unreadable file like any other Remove the INDD signature check and message, and .indd/.indt from the Open filters and drop routing. The alert tests use a generic unreadable file. * Control channel: a failed app.open answers with the error and opens no alert An alert waits for a click that a control client can't give. app.open reports the error in its response, as engine.execute does; File › Open, drops and the command line still show the alert.
This commit is contained in:
1 parent
07b95eb7b3
commit
9fbd6d2402
6 files changed
+160
-21
No files matched your search
@@ -245,7 +245,7 @@ fn main() -> eframe::Result {
|
||||
let _ = app.run("file.newSample", serde_json::json!({}));
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) {
|
||||
eprintln!("designcraft: {f}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,7 +803,9 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let mut result: Option<bool> = None;
|
||||
let alert_title = d.s("title");
|
||||
let title = match d.id.as_str() {
|
||||
"alert" => crate::i18n::tr(&app.ui.language, &alert_title),
|
||||
"ruby" => crate::i18n::tr(&app.ui.language, "Ruby"),
|
||||
"newDocument" => crate::i18n::tr(&app.ui.language, "New Document"),
|
||||
"frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"),
|
||||
@@ -837,7 +839,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
};
|
||||
egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
|
||||
ui.set_min_width(380.0);
|
||||
ui.set_max_width(if d.id == "newDocument" && crate::i18n::is_rtl(&app.ui.language) { 380.0 } else { 640.0 });
|
||||
ui.set_max_width(match d.id.as_str() {
|
||||
"alert" => 440.0,
|
||||
"newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0,
|
||||
_ => 640.0,
|
||||
});
|
||||
if crate::i18n::is_rtl(&app.ui.language) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -1366,6 +1372,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
"documentSetup" => document_setup(app, ui, &mut d),
|
||||
"alert" => {
|
||||
let file = d.s("file");
|
||||
if !file.is_empty() {
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0)));
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
crate::rtl::label(ui, d.s("message"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
@@ -1381,7 +1395,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
{
|
||||
result = Some(true);
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
// An alert only has OK.
|
||||
let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked();
|
||||
if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
result = Some(false);
|
||||
}
|
||||
});
|
||||
@@ -1498,7 +1514,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
}
|
||||
"pdfImport" => {
|
||||
let crop = d.s("crop");
|
||||
app.run("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
|
||||
app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
|
||||
}
|
||||
"print" => {
|
||||
let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) };
|
||||
@@ -1601,7 +1617,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
app.run("document.preferences", doc)
|
||||
}
|
||||
"newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})),
|
||||
"importOptions" => app.run(
|
||||
"importOptions" => app.open_file(
|
||||
"file.place",
|
||||
json!({"path": d.s("path"), "removeStyles": d.b("removeStyles"), "styleConflicts": d.s("styleConflicts"), "styleMap": d.fields.get("map").cloned().unwrap_or(json!({}))}),
|
||||
),
|
||||
@@ -1669,6 +1685,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
}
|
||||
app.run("footnote.options", p)
|
||||
}
|
||||
"alert" => Ok(Value::Null),
|
||||
"findChange" if d.b("objectMode") => Ok(Value::Null),
|
||||
"findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})),
|
||||
id if id.starts_with("cmd:") => {
|
||||
|
||||
@@ -230,6 +230,26 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Business Card", ["Business Card", "Business Card", "Business Card", "Business Card", "名片"]),
|
||||
("CMYK", ["CMYK", "CMYK", "CMYK", "CMYK", "CMYK"]),
|
||||
("Cancel", ["Cancel", "Cancel", "Cancel", "Cancel", "取消"]),
|
||||
(
|
||||
"Can't Open the File",
|
||||
[
|
||||
"Datei kann nicht geöffnet werden",
|
||||
"Impossible d'ouvrir le fichier",
|
||||
"No se puede abrir el archivo",
|
||||
"ファイルを開けません",
|
||||
"无法打开文件",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Can't Place the File",
|
||||
[
|
||||
"Datei kann nicht platziert werden",
|
||||
"Impossible d'importer le fichier",
|
||||
"No se puede colocar el archivo",
|
||||
"ファイルを配置できません",
|
||||
"无法置入文件",
|
||||
],
|
||||
),
|
||||
("Center Content", ["Center Content", "Center Content", "Center Content", "Center Content", "内容居中"]),
|
||||
("Change All", ["Change All", "Change All", "Change All", "Change All", "全部更改"]),
|
||||
("Change to:", ["Change to:", "Change to:", "Change to:", "Change to:", "更改为:"]),
|
||||
|
||||
@@ -167,6 +167,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Dock panel", "إرساء اللوحة"),
|
||||
("OK", "موافق"),
|
||||
("Cancel", "إلغاء"),
|
||||
("Can't Open the File", "تعذّر فتح الملف"),
|
||||
("Can't Place the File", "تعذّر إدراج الملف"),
|
||||
("Apply", "تطبيق"),
|
||||
("Done", "تم"),
|
||||
("Reset", "إعادة ضبط"),
|
||||
|
||||
+115
-15
@@ -58,8 +58,8 @@ pub struct Services {
|
||||
/// Hand bytes to the user as a named file (browser download). When set, Save uses it
|
||||
/// instead of writing to a path.
|
||||
pub download: Option<DownloadFn>,
|
||||
/// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`,
|
||||
/// anything else → `file.place`.
|
||||
/// Files delivered asynchronously, drained every frame: documents ([`opens_as_document`]) →
|
||||
/// `file.openBytes`, `.ase` → `swatch.load`, anything else → `file.place`.
|
||||
pub inbox: Option<Inbox>,
|
||||
}
|
||||
|
||||
@@ -539,10 +539,10 @@ impl DesignApp {
|
||||
self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages})));
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
self.run(cmd, json!({"path": path}))
|
||||
self.open_file(cmd, json!({"path": path}))
|
||||
}
|
||||
}
|
||||
Some(path) => self.run(cmd, json!({"path": path})),
|
||||
Some(path) => self.open_file(cmd, json!({"path": path})),
|
||||
None => Ok(Value::Null),
|
||||
};
|
||||
}
|
||||
@@ -561,11 +561,11 @@ impl DesignApp {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let r = if lower.ends_with(".ase") {
|
||||
self.run("swatch.load", json!({"base64": b64}))
|
||||
} else if lower.ends_with(".designcraft") || lower.ends_with(".idml") {
|
||||
} else if opens_as_document(&name) {
|
||||
let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem);
|
||||
self.run("file.openBytes", json!({"name": title, "base64": b64}))
|
||||
self.open_file("file.openBytes", json!({"name": title, "base64": b64}))
|
||||
} else {
|
||||
self.run("file.place", json!({"name": name, "base64": b64}))
|
||||
self.open_file("file.place", json!({"name": name, "base64": b64}))
|
||||
};
|
||||
if let Err(e) = r {
|
||||
self.status(format!("{name}: {e}"));
|
||||
@@ -636,18 +636,34 @@ impl DesignApp {
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
{
|
||||
let p = f.path().to_string_lossy().to_string();
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let lp = p.to_ascii_lowercase();
|
||||
let cmd = if lp.ends_with(".designcraft") || lp.ends_with(".idml") { "file.open" } else { "file.place" };
|
||||
let _ = self.run(cmd, json!({"path": p}));
|
||||
let p = f.path().to_string_lossy().to_string();
|
||||
if !p.is_empty() {
|
||||
let _ = self.open_dropped(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file dropped on the window: documents open, anything else is placed.
|
||||
pub fn open_dropped(&mut self, path: &str) -> Result<Value, String> {
|
||||
let cmd = if opens_as_document(path) { "file.open" } else { "file.place" };
|
||||
self.open_file(cmd, json!({"path": path}))
|
||||
}
|
||||
|
||||
/// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file
|
||||
/// that can't be opened or placed gets an alert with the reason, as well as the status line.
|
||||
pub fn open_file(&mut self, cmd: &str, params: Value) -> Result<Value, String> {
|
||||
let file = match params.get("path").and_then(Value::as_str) {
|
||||
Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()),
|
||||
None => params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
|
||||
};
|
||||
let r = self.run(cmd, params);
|
||||
if let Err(e) = &r {
|
||||
let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" };
|
||||
self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e})));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Inject synthetic events (one pointer event per frame).
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if self.synthetic.is_empty() {
|
||||
@@ -856,6 +872,13 @@ impl DesignApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Files that open as documents (when dropped or picked) rather than being placed: DesignCraft
|
||||
/// and IDML.
|
||||
pub fn opens_as_document(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
[".designcraft", ".idml"].iter().any(|ext| n.ends_with(ext))
|
||||
}
|
||||
|
||||
pub fn now_ms() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -883,4 +906,81 @@ mod tests {
|
||||
assert!(!ui.snap_view().snap_to_guides);
|
||||
assert_eq!(ui.snap_view().zone_px, 0.0);
|
||||
}
|
||||
|
||||
/// A temporary file named `name` whose bytes aren't a document or a graphic.
|
||||
fn unreadable_file(name: &str) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("designcraft-ui-{}-{name}", std::process::id()));
|
||||
std::fs::write(&path, b"neither a document nor a graphic").unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// The open alert dialog: (title, message).
|
||||
fn alert(app: &DesignApp) -> (String, String) {
|
||||
let d = app.ui.dialog.as_ref().expect("an alert is open");
|
||||
assert_eq!(d.id, "alert");
|
||||
(d.fields["title"].as_str().unwrap().to_string(), d.fields["message"].as_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_documents_open_and_the_rest_is_placed() {
|
||||
for name in ["a.designcraft", "B.IDML"] {
|
||||
assert!(opens_as_document(name), "{name}");
|
||||
}
|
||||
for name in ["a.png", "b.pdf", "idml.txt", "c.idml.zip"] {
|
||||
assert!(!opens_as_document(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_an_unreadable_file_alerts() {
|
||||
// A document opens, so it says it can't be opened.
|
||||
let path = unreadable_file("broken.designcraft");
|
||||
let mut app = DesignApp::new(Session::new(), Services::default());
|
||||
let r = app.open_dropped(&path.to_string_lossy());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let e = r.unwrap_err();
|
||||
let (title, message) = alert(&app);
|
||||
assert_eq!(title, "Can't Open the File", "a document opens, it isn't placed");
|
||||
assert_eq!(message, e);
|
||||
assert!(app.ui.status.contains(&e), "the status line says it too");
|
||||
assert!(app.session.documents().is_empty());
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "OK closes the alert");
|
||||
// Anything else is placed, so it says it can't be placed.
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let path = unreadable_file("notes.xyz");
|
||||
let r = app.open_dropped(&path.to_string_lossy());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let e = r.unwrap_err();
|
||||
assert_eq!(alert(&app), ("Can't Place the File".to_string(), e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_open_failures_show_an_alert() {
|
||||
let path = unreadable_file("open.designcraft");
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
let services = Services { pick_open: Some(Box::new(move |_| Some(picked.clone()))), ..Default::default() };
|
||||
let mut app = DesignApp::new(Session::new(), services);
|
||||
let e = app.run("app.openDialog", json!({})).unwrap_err();
|
||||
assert_eq!(alert(&app), ("Can't Open the File".to_string(), e));
|
||||
// Placing it says so too.
|
||||
app.ui.dialog = None;
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let e = app.run("app.placeDialog", json!({})).unwrap_err();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(alert(&app), ("Can't Place the File".to_string(), e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_command_errors_stay_in_the_status_line() {
|
||||
let mut app = DesignApp::new(Session::new(), Services::default());
|
||||
assert!(app.run("file.open", json!({"path": "/nonexistent/x.designcraft"})).is_err());
|
||||
assert!(app.ui.dialog.is_none(), "a scripted file.open doesn't raise an alert");
|
||||
assert!(!app.ui.status.is_empty());
|
||||
// The control channel's app.open answers with the error; no alert waits for a click.
|
||||
let (req, _rx) = control::ControlRequest::new("app.open", json!({"path": "/nonexistent/x.designcraft"}));
|
||||
let control::Outcome::Done(r) = control::handle(&mut app, &egui::Context::default(), &req) else { panic!("app.open answers") };
|
||||
assert_eq!(r["ok"], json!(false), "{r}");
|
||||
assert!(app.ui.dialog.is_none(), "a control-channel app.open doesn't raise an alert");
|
||||
}
|
||||
}
|
||||
@@ -704,7 +704,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) else { return Some(Ok(Value::Null)) };
|
||||
let l = path.to_lowercase();
|
||||
if !(l.ends_with(".docx") || l.ends_with(".rtf")) {
|
||||
return Some(app.run("file.place", json!({"path": path})));
|
||||
return Some(app.open_file("file.place", json!({"path": path})));
|
||||
}
|
||||
let info = match app.run("place.styles", json!({"path": path})) {
|
||||
Ok(v) => v,
|
||||
|
||||
Reference in new issue
Block a user