diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 73c5da31..ecfb281b 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -134,12 +134,25 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: } /// Where a file goes when no path was given: the suggested name on the web (a download), else the -/// save panel's choice. +/// save panel's choice, given the suggested name's extension when it names no file type (a name +/// typed without one). fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result { if is_web(app) { return Ok(pick.name.clone()); } - app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into()) + let picked = app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or("cancelled")?; + Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true)) +} + +/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format +/// `keeps` takes, else with `.ext` added. +fn with_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { + let has = fileio::extension(path); + if ext.is_empty() || has.eq_ignore_ascii_case(ext) || fileio::format_for_name(path).is_some_and(keeps) { + path.to_string() + } else { + format!("{path}.{ext}") + } } /// The active document's file name with extension `ext`, and its folder. @@ -172,10 +185,7 @@ pub fn remember_view(app: &mut VectorcraftApp) { /// A path typed in a save panel: kept when its extension names a save format (the panel's file /// type), else `f`'s extension is added. fn with_save_extension(path: &str, f: &Format) -> String { - match fileio::format_for_name(path) { - Some(g) if SAVE_FORMATS.contains(&g.id) => path.to_string(), - _ => format!("{path}.{}", f.extensions[0]), - } + with_extension(path, f.extensions[0], |g| SAVE_FORMATS.contains(&g.id)) } /// Write every file of an export (one per artboard, linked images) for the destination `path`. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3e168386..059e0106 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -74,6 +74,8 @@ mod tests_recovery; #[cfg(test)] mod tests_save; #[cfg(test)] +mod tests_saveext; +#[cfg(test)] mod tests_slices; #[cfg(test)] mod tests_svg; diff --git a/crates/ui-egui/src/tests_saveext.rs b/crates/ui-egui/src/tests_saveext.rs new file mode 100644 index 00000000..e1a896b6 --- /dev/null +++ b/crates/ui-egui/src/tests_saveext.rs @@ -0,0 +1,74 @@ +//! A name typed in a save panel without an extension gets the chosen format's: Save, Save As, +//! Save a Copy, Export, Export Selection and other writes through a save panel. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{FilePick, Services, VectorcraftApp, io}; + +type Written = Rc>>; + +/// An app with a document whose save panel answers `typed` and whose writer records the paths. +fn saving_app(typed: &'static str) -> (VectorcraftApp, Written) { + let written = Written::default(); + let w = written.clone(); + let services = Services { + write: Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })), + pick_save: Some(Box::new(move |_: &FilePick| Some(typed.to_string()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + (app, written) +} + +fn last(w: &Written) -> String { + w.borrow().last().cloned().unwrap_or_default() +} + +#[test] +fn saves_add_the_format_extension_to_a_bare_name() { + let (mut app, w) = saving_app("/tmp/Untitled-1"); + app.run("file.save", json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/Untitled-1.vectorcraft"); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/Untitled-1.vectorcraft")); + let (mut app, w) = saving_app("/tmp/copy"); + app.run("file.saveCopy", json!({"format": "svg", "svg": {}})).unwrap(); + assert_eq!(last(&w), "/tmp/copy.svg"); + let (mut app, w) = saving_app("/tmp/as"); + app.run("file.saveAs", json!({"format": "pdf", "options": {}})).unwrap(); + assert_eq!(last(&w), "/tmp/as.pdf"); +} + +#[test] +fn exports_add_the_format_extension_to_a_bare_name() { + let (mut app, w) = saving_app("/tmp/art"); + io::export(&mut app, Some("png"), None, &json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/art.png"); + app.run("select.all", json!({})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/art.png", "Export Selection: its default format"); + io::save_command_output(&mut app, "document.export", "txt", json!({"format": "txt"})).unwrap(); + assert_eq!(last(&w), "/tmp/art.txt"); +} + +#[test] +fn typed_extensions_are_kept() { + let (mut app, w) = saving_app("/tmp/art.svg"); + io::export(&mut app, Some("svg"), None, &json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/art.svg"); + let (mut app, w) = saving_app("/tmp/My.Poster.SVG"); + io::export(&mut app, Some("svg"), None, &json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/My.Poster.SVG"); + // A dot in the name isn't an extension. + let (mut app, w) = saving_app("/tmp/v1.2"); + io::export(&mut app, Some("png"), None, &json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/v1.2.png"); +}