Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ egui_kittest = "0.37"
# Monotonic clock on native and web (`Instant`); screenshot settlement must not use wall time.
web-time = "1"
rfd = "0.17"
# Native file and message dialogs that never block the event loop (craft-libs).
craft-dialogs = { git = "https://github.com/storytold/craft-libs", rev = "1a11a4897865f7410c40d25ca6e452ce399a1a2c" }
raw-window-handle = "0.6"
arboard = { version = "3.6", default-features = false }

[workspace.lints.rust]
Expand Down
2 changes: 0 additions & 2 deletions apps/designcraft-web/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,6 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
ctx.request_repaint();
});
})),
// Exports ask for a name; the browser decides where the download goes.
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
write: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))),
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))),
inbox: Some(inbox),
Expand Down
2 changes: 2 additions & 0 deletions apps/designcraft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ log = { workspace = true }
winresource = "0.1"

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Not called here: craft-dialogs shows the dialogs. rfd's default features keep its Wayland
# support on, so portal dialogs get the window as their parent.
rfd = { workspace = true }
arboard = { workspace = true }

Expand Down
205 changes: 4 additions & 201 deletions apps/designcraft/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ impl eframe::App for App {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.startup.enter();
// `App::ui` is the only signal that a real layout pass ran. A discarded multipass first
// pass reports index 0 once, so these once-per-frame counters/menu polls still run once.
Expand Down Expand Up @@ -96,6 +96,9 @@ impl eframe::App for App {
}
#[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
// After the frame, never inside it: the dialog answers on a later frame. On macOS Save and
// message boxes are sheets on this window.
self.app.show_file_dialogs(Some(&*frame));
}
fn on_exit(&mut self) {
// A normal exit before the third frame is not a crash.
Expand Down Expand Up @@ -216,157 +219,8 @@ fn save_prefs(app: &DesignApp) {
}
}

/// One file-type row in an open dialog. `open_filters` is what `pick_open` applies.
struct OpenFilter {
name: &'static str,
extensions: &'static [&'static str],
}

fn open_filters(purpose: &str) -> &'static [OpenFilter] {
match purpose {
"swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }],
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
"shortcuts" => &[OpenFilter { name: "Shortcut set (JSON, InDesign text)", extensions: &["json", "txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
"library" => &[OpenFilter { name: "Object Library", extensions: &["dclib"] }],
"relink" => &[OpenFilter {
name: "Graphics",
extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"],
}],
"dataMerge" => &[OpenFilter { name: "Data source (CSV, TSV, text, Excel)", extensions: &["csv", "tsv", "tab", "txt", "xlsx"] }],
"place" => &[
OpenFilter {
name: "Graphics and text",
extensions: &[
"png",
"jpg",
"jpeg",
"gif",
"webp",
"tif",
"tiff",
"bmp",
"psd",
"svg",
"pdf",
"ai",
"eps",
"txt",
"docx",
"rtf",
"md",
"xlsx",
"idml",
"designcraft",
"mp4",
"m4v",
"mov",
"webm",
"mp3",
"m4a",
"wav",
"ogg",
],
},
OpenFilter {
name: "Graphics",
extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"],
},
OpenFilter { name: "Text (Word, RTF, plain, Excel)", extensions: &["docx", "rtf", "txt", "md", "xlsx"] },
OpenFilter { name: "Video and sound", extensions: &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"] },
],
_ => &[
OpenFilter { name: "DesignCraft, IDML or InDesign", extensions: &["designcraft", "idml", "indd", "indt"] },
OpenFilter { name: "DesignCraft", extensions: &["designcraft"] },
OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] },
OpenFilter { name: "InDesign document or template (INDD, INDT)", extensions: &["indd", "indt"] },
// Files saved without an extension; `file.open` tells the formats apart by content.
OpenFilter { name: "All files", extensions: &[ALL_FILES] },
],
}
}

/// The "All files" pattern. The macOS panel merges every filter into one list of allowed types,
/// which takes type identifiers as well as extensions: `public.data` admits any file.
const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*" };

/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one.
/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it.
fn suggested_extension(name: &str) -> Option<&str> {
std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace))
}

/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has
/// none (Linux dialogs don't add it). A document always ends in `.designcraft`, as `file.save`
/// writes it.
fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path::PathBuf {
let Some(ext) = ext else { return picked };
if ext.eq_ignore_ascii_case("designcraft") {
let p = picked.to_string_lossy();
return designcraft_engine::cmd::with_document_extension(&p).into();
}
if picked.extension().is_some() || picked.file_name().is_none() {
return picked;
}
let mut s = picked.into_os_string();
s.push(".");
s.push(ext);
s.into()
}

/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in
/// front (the document's, or the last one used) starts there while that folder still exists;
/// otherwise the system picks, as it did before.
fn save_dialog_start(suggested: &str) -> (Option<std::path::PathBuf>, String) {
let path = std::path::Path::new(suggested);
let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string());
let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf);
(folder, name)
}

/// The save dialog: the suggested name's extension as its filter, and the extension added to a
/// name typed without it. Adding it names another file than the one the dialog checked, so
/// replacing an existing one asks again.
fn pick_save(suggested: &str) -> Option<String> {
let (folder, name) = save_dialog_start(suggested);
let name = name.as_str();
let ext = suggested_extension(name);
let mut dialog = rfd::FileDialog::new().set_file_name(name);
if let Some(folder) = folder {
dialog = dialog.set_directory(folder);
}
if let Some(ext) = ext {
dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]);
}
let picked = dialog.save_file()?;
let path = picked_save_path(picked.clone(), ext);
if path != picked && path.exists() {
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let answer = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Warning)
.set_title("Replace File?")
.set_description(format!("“{file}” already exists. Do you want to replace it?"))
.set_buttons(rfd::MessageButtons::YesNo)
.show();
if answer != rfd::MessageDialogResult::Yes {
return None;
}
}
Some(path.to_string_lossy().to_string())
}

fn services() -> Services {
Services {
pick_open: Some(Box::new(|purpose: &str| {
let mut dialog = rfd::FileDialog::new();
for filter in open_filters(purpose) {
dialog = dialog.add_filter(filter.name, filter.extensions);
}
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
pick_save: Some(Box::new(pick_save)),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))),
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())),
Expand Down Expand Up @@ -636,16 +490,6 @@ mod tests {
assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept");
}

#[test]
fn relink_open_dialog_lists_graphics() {
let filters = super::open_filters("relink");
let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect();
for ext in ["png", "jpg", "tif", "psd", "svg", "pdf", "ai", "eps"] {
assert!(exts.contains(&ext), "{ext} is missing from the relink dialog: {exts:?}");
}
assert!(!exts.contains(&"designcraft") && !exts.contains(&"idml"));
}

#[test]
fn control_port_values_that_are_not_ports_are_ignored() {
assert_eq!(super::control_port_from("--control", Some("7979".into())), Some(7979));
Expand All @@ -655,47 +499,6 @@ mod tests {
assert_eq!(super::control_port_from("--control", Some(bad.into())), None, "{bad:?}");
}
}

#[test]
fn data_merge_open_dialog_lists_table_extensions() {
let filters = super::open_filters("dataMerge");
let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect();
for ext in ["csv", "tsv", "tab", "txt", "xlsx"] {
assert!(exts.contains(&ext), "{ext} is missing from the dataMerge dialog: {exts:?}");
}
assert!(!exts.iter().any(|ext| *ext == "designcraft" || *ext == "idml"), "dataMerge must not fall through to the document filters: {exts:?}");
let place = super::open_filters("place");
assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file");
assert!(place.iter().any(|filter| filter.extensions.contains(&"png")));
let documents = super::open_filters("");
for ext in ["designcraft", "idml", "indd", "indt"] {
assert!(documents.first().is_some_and(|filter| filter.extensions.contains(&ext)), "{ext} is missing from the first Open filter");
}
assert!(documents.last().is_some_and(|filter| filter.extensions == [super::ALL_FILES]), "files without an extension can be picked");
}

#[test]
fn save_dialog_answers_get_the_suggested_extension() {
use std::path::PathBuf;
let save = |picked: &str, name: &str| super::picked_save_path(PathBuf::from(picked), super::suggested_extension(name));
assert_eq!(save("/d/Brochure", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft"));
assert_eq!(save("/d/Brochure.idml", "Untitled.designcraft"), PathBuf::from("/d/Brochure.idml.designcraft"));
assert_eq!(save("/d/Brochure.designcraft", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft"));
assert_eq!(save("/d/Brochure", "Untitled.pdf"), PathBuf::from("/d/Brochure.pdf"));
assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept");
assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension");
}

/// #1653: the save dialog opens in the folder the suggestion names, unless it is gone.
#[test]
fn save_dialog_starts_in_the_suggested_folder_while_it_exists() {
let folder = std::env::temp_dir();
let suggested = folder.join("Brochure.pdf");
assert_eq!(super::save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string()));
let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf");
assert_eq!(super::save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own");
assert_eq!(super::save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own");
}
}

#[cfg(all(test, unix))]
Expand Down
2 changes: 2 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
{ path = "rfd::FileDialog", reason = "use DesignApp::ask_open/ask_save (craft-dialogs): a blocking dialog inside the frame aborts the app on macOS" },
{ path = "rfd::MessageDialog", reason = "use craft-dialogs' ask_message: a blocking dialog inside the frame aborts the app on macOS" },
]
cognitive-complexity-threshold = 30
4 changes: 4 additions & 0 deletions crates/engine/src/tests/file_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const ALLOWED: &[(&str, &str)] = &[
"fonts/src/fontdb/system.rs",
"the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either",
),
(
"ui-egui/src/app/file_dialogs.rs",
"the host's own file dialogs: it only tests whether a path the user picked in the system dialog exists (the replace-file question, the folder a Save dialog opens in), then re-runs the command with that path, which the engine checks as usual",
),
(
"mcp/src/tools.rs",
"`screenshot` reads back the PNG the connected app just wrote. Connected mode cannot be combined with the roots (the CLI refuses the pair), and the app's own engine checks the path it writes",
Expand Down
3 changes: 3 additions & 0 deletions crates/ui-egui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ toml = "1"

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
# File and message dialogs that never block the frame; the browser build uses its own path.
craft-dialogs = { workspace = true }
raw-window-handle = { workspace = true }
11 changes: 11 additions & 0 deletions crates/ui-egui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use super::{
mod clipboard;
mod commands;
mod control;
pub(crate) mod file_dialogs;
mod files;
mod frame;
mod ime;
Expand All @@ -34,8 +35,18 @@ impl DesignApp {
// The app owns the monotonic origin for recovery timing and frame pacing; `now_ms` itself
// is process-global so screenshot deadlines stay comparable across hosts.
let now = now_ms() / 1000.0;
#[cfg(not(target_arch = "wasm32"))]
let dialog_wake = std::sync::Arc::new(std::sync::OnceLock::new());
DesignApp {
pending_pdf: None,
#[cfg(not(target_arch = "wasm32"))]
file_dialogs: file_dialogs::new_dialogs(dialog_wake.clone()),
#[cfg(not(target_arch = "wasm32"))]
asked: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
held_answers: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
dialog_wake,
session,
ui: UiState::default(),
services,
Expand Down
Loading
Loading