From 8d1bd19ee29aa3a7f7d8730214fa8d6b90a4e7a6 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Sat, 31 Oct 2026 01:46:34 +0600 Subject: [PATCH] Save As suggests the default save format for opened drawings too (#2199) (#2509) * Save As suggests the default save format for opened drawings too (#2199) Options > Open and Save > "Save as" in AutoCAD is the format every drawing is saved in. CADCraft applied File > Default Save Format only to untitled drawings, so Save As on an opened DWG kept DWG with DXF chosen, and the other way round. Save As now swaps a DWG, DXF or DWT extension for the chosen format; other names, and Save of an opened file, are unchanged. Co-Authored-By: Claude Opus 5.5 * Default Save Format's echo says Save As will suggest the format (#2199) "Save As will save drawings as DWG" promised more than Save As does: it only swaps a DWG, DXF or DWT extension in the name it suggests, and the path picked in the dialog decides the format. The echo now reads "Save As will suggest DWG", and default_save_format_applies_to_opened_drawings pins it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 36 ++++++++++++++++++---------- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/tests/save_version.rs | 34 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 311dd4cbf..91902017f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -118,7 +118,7 @@ pub struct UiState { /// Animation effects (dialog and menu fades, smoothed scrolling): "system" (follow the OS /// setting), "on" or "off". pub animations: animation::AnimationPref, - /// The format Save As suggests for a drawing that has no file yet: "dxf" or "dwg". + /// The format Save As suggests: "dxf" or "dwg", for new drawings and opened files alike. pub save_format: SaveFormat, /// The version Save As writes for a drawing that has no file yet ("2000" … "2018"); a /// drawing opened from a file keeps that file's version. @@ -132,8 +132,8 @@ pub struct UiState { /// A DXF/DWG file version (2000 … 2018). pub use cadcraft_engine::cmd::file::Version as SaveVersion; -/// The default save format for new drawings, like AutoCAD's Options > Open and Save > "Save as". -/// A drawing opened from a file keeps that file's format. +/// The default save format, like AutoCAD's Options > Open and Save > "Save as": the format Save As +/// suggests for every drawing, an opened DWG or DXF too. Save (QSAVE) of an opened file keeps its file. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SaveFormat { @@ -158,13 +158,19 @@ impl SaveFormat { SaveFormat::ALL.into_iter().find(|f| f.as_str().eq_ignore_ascii_case(s.trim().trim_start_matches('.'))) } - /// The name Save As suggests: a title that already has an extension (a drawing opened from a - /// file) keeps it; an untitled drawing gets this format's extension. + /// The name Save As suggests: this format's extension, replacing a drawing's own (DWG, DXF or + /// DWT, from a drawing opened from a file); any other extension is kept. pub fn suggested_name(self, title: Option<&str>) -> String { - match title.map(str::trim).filter(|t| !t.is_empty()) { - Some(t) if std::path::Path::new(t).extension().is_some() => t.to_owned(), - Some(t) => format!("{t}.{}", self.as_str()), - None => format!("Drawing.{}", self.as_str()), + let Some(t) = title.map(str::trim).filter(|t| !t.is_empty()) else { return format!("Drawing.{}", self.as_str()) }; + let path = std::path::Path::new(t); + match path.extension().and_then(|e| e.to_str()) { + Some(e) if ["dwg", "dxf", "dwt"].iter().any(|d| d.eq_ignore_ascii_case(e)) => { + // the extension is ASCII, so this is a char boundary + let stem = t.get(..t.len().saturating_sub(e.len() + 1)).unwrap_or(t); + format!("{stem}.{}", self.as_str()) + } + Some(_) => t.to_owned(), + None => format!("{t}.{}", self.as_str()), } } } @@ -1142,11 +1148,17 @@ mod tests { b.start("ui.saveformat.dwg"); b.start("ui.resetpalettes"); assert_eq!(b.ui.save_format, SaveFormat::Dwg); - // Untitled drawings get the format's extension; files keep their own. + // Untitled drawings get the format's extension, and so do opened drawings (#2199). assert_eq!(SaveFormat::Dwg.suggested_name(Some("Drawing1")), "Drawing1.dwg"); assert_eq!(SaveFormat::Dxf.suggested_name(Some("Drawing1")), "Drawing1.dxf"); - assert_eq!(SaveFormat::Dwg.suggested_name(Some("Floorplan.dxf")), "Floorplan.dxf"); - assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.DWG")), "Floorplan.DWG"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("Floorplan.dxf")), "Floorplan.dwg"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.DWG")), "Floorplan.dxf"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.dxf")), "Floorplan.dxf"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("Title block.dwt")), "Title block.dwg"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("v1.2 plan.Dxf")), "v1.2 plan.dwg"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("平面図.dxf")), "平面図.dwg"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some(".dxf")), ".dxf.dwg", "a dot file has no extension"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("logo.svg")), "logo.svg", "other files keep their type"); assert_eq!(SaveFormat::Dwg.suggested_name(None), "Drawing.dwg"); assert_eq!(SaveFormat::Dwg.suggested_name(Some(" ")), "Drawing.dwg"); let ui: UiState = serde_json::from_value(json!({ "saveFormat": "dwg" })).unwrap_or_default(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 58055542f..797952edf 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -520,7 +520,7 @@ fn saveas_params(ui: &crate::UiState, session: &cadcraft_engine::Session, path: fn set_save_format(app: &mut CadApp, format: crate::SaveFormat) -> Value { app.ui.save_format = format; - app.session.echo(format!("New drawings will be saved as {}", format.as_str().to_ascii_uppercase())); + app.session.echo(format!("Save As will suggest {}", format.as_str().to_ascii_uppercase())); json!({ "saveFormat": format.as_str() }) } diff --git a/crates/ui-egui/tests/save_version.rs b/crates/ui-egui/tests/save_version.rs index 47b716b89..402c43623 100644 --- a/crates/ui-egui/tests/save_version.rs +++ b/crates/ui-egui/tests/save_version.rs @@ -44,3 +44,37 @@ fn default_save_version_applies_to_new_drawings_and_persists() { b.start("ui.resetpalettes"); assert_eq!(b.ui.save_version, "2004"); } + +/// The default save format (#2199) is what Save As suggests for an opened DWG or DXF too, as +/// AutoCAD's Options > Open and Save > "Save as" does; Save keeps the opened file. +#[test] +fn default_save_format_applies_to_opened_drawings() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"plain".to_vec()), plot: None }); + set_write_as(with_options); + let dir = std::env::temp_dir().join(format!("cadcraft-ui-save-format-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let suggested = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let (seen, to) = (suggested.clone(), dir.clone()); + let pick = move |name: &str| { + seen.lock().unwrap().push(name.to_string()); + Some(to.join(name).to_string_lossy().to_string()) + }; + let mut app = CadApp::new(Session::empty(), Services { pick_save: Some(Box::new(pick)), ..Services::default() }); + for (opened, format, expected) in + [("Plan.dwg", "dxf", "Plan.dxf"), ("Plan.DXF", "dwg", "Plan.dwg"), ("Plan.dxf", "dxf", "Plan.dxf"), ("Sheet.dwt", "dwg", "Sheet.dwg")] + { + let file = dir.join(opened); + std::fs::write(&file, b"0\nEOF\n").unwrap(); + app.run("open", json!({ "path": file.to_string_lossy() })).unwrap(); + app.run("ui.saveformat", json!({ "format": format })).unwrap(); + // the picker's path decides the format, so the echo promises only the suggestion + let echo = format!("Save As will suggest {}", format.to_ascii_uppercase()); + assert_eq!(app.session.log.last(), Some(&echo), "{opened} with {format}"); + app.run("ui.saveas", Value::Null).unwrap(); + assert_eq!(suggested.lock().unwrap().last().map(String::as_str), Some(expected), "{opened} with {format}"); + let path = app.session.state().unwrap().path.clone().unwrap_or_default(); + assert!(path.ends_with(expected), "{opened}: saved as {path}"); + assert!(dir.join(expected).exists()); + } + let _ = std::fs::remove_dir_all(&dir); +}