Browse / github.com/storytold/cadcraft / patch
Save As dialog: file type, version and binary DXF before the file picker
Save As dialog: file type, version and binary DXF before the file picker ### Before The host's file picker (rfd) can't report which file type the user picked. Save As therefore saved in File ▸ Default Save Format's format and version, and a drawing could not be saved as, say, DWG 2004 once, for a colleague, from the dialog. ### After Save As (menu, tool bar, Cmd+Shift+S or typed SAVEAS) opens a small **Save As dialog before the picker**: - **File type:** Drawing (DXF), Drawing (DWG), Drawing Template (DWT), SVG, PNG, PDF. - **Version:** AutoCAD 2000 … 2018, for DXF and DWG. It starts from the drawing's own version, or from the default save format and version for a
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- KvasFan (imported from pull request #2172, open)
- Base
dfddd13656bde4ed7f6c579d85e34874650bc31a- Size
- +419 −9 in 11 files
- Review
- pending
- Shared
- 2026-10-28
- Id
8ff00df547138591abbb0dec
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/saveas.rs crates/ui-egui/src/saveas/tests.rs crates/ui-egui/tests/it/dialog_dispatch.rs crates/ui-egui/tests/save_version.rs docs/gaps.md
@@ -8,6 +8,8 @@ use serde_json::json; use crate::CadApp; use crate::theme::Tokens; +/// Draw the open dialog (`app.ui.dialog`), the MTEXT editor and Quick Properties for this frame; a+/// dialog closed by its own button is cleared. pub fn show(app: &mut CadApp, ctx: &egui::Context) { crate::mtext_editor::show(app, ctx); crate::quick::quick_properties(app, ctx);@@ -21,6 +23,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "parameters" => crate::parametric::parameters_dialog(app, ctx, &mut open), "dsettings" => dsettings(app, ctx, &mut open), "units" => units(app, ctx, &mut open),+ "saveas" => crate::saveas::dialog(app, ctx, &mut open), "hatch" => crate::hatchdlg::dialog(app, ctx, &mut open), "aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open), "about" => about(ctx, &mut open),@@ -1365,3 +1365,13 @@ menu Modify Edita Check for Updates… Verifica aggiornamenti… CADCraft is up to date. CADCraft è aggiornato. Could not check for updates. Impossibile verificare gli aggiornamenti.+# Save As dialog: file type, version, binary DXF.+ Save As (file picker) Salva con nome (selezione file)+ File type Tipo di file+ Save... Salva...+ Binary DXF DXF binario+ An export of the drawing as shown: the drawing keeps its own file. Un'esportazione del disegno com'è mostrato: il disegno mantiene il proprio file.+ Units of new drawings Unità dei nuovi disegni+ Imperial (inches) Imperiali (pollici)+ Metric (millimetres) Metriche (millimetri)+ New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Nuovo e il primo disegno all'avvio usano queste unità, a meno che non sia impostato un modello predefinito (QNEWTEMPLATE).@@ -59,6 +59,8 @@ fn translation_gaps() { } } +/// The command labels, menu names, tool names and snap mode names that `language`'s catalog has no+/// row for. fn missing_commands_menus_tools_and_snap_labels(language: &str) -> Vec<String> { let catalog = catalog(language).unwrap(); let mut sources = BTreeSet::new();@@ -81,6 +83,7 @@ fn missing_commands_menus_tools_and_snap_labels(language: &str) -> Vec<String> { sources.into_iter().filter(|s| !s.is_empty() && !catalog.contains_key(&("", *s))).map(str::to_string).collect() } +/// The `tl!`/`tf!` literals of the listed UI source files that `language`'s catalog has no row for. fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> { let catalog = catalog(language).unwrap(); let sources = [@@ -92,6 +95,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> { include_str!("../palettes/quickcalc.rs"), include_str!("../palettes/patterns.rs"), include_str!("../dialogs.rs"),+ include_str!("../saveas.rs"), include_str!("../hatchdlg.rs"), include_str!("../aliasdlg.rs"), include_str!("../mtext_editor.rs"),@@ -1371,3 +1371,9 @@ Check for Updates… Перевірити оновлення… CADCraft is up to date. Встановлено найновішу версію CADCraft. Could not check for updates. Не вдалося перевірити оновлення.+# Save As dialog: file type, version, binary DXF.+ Save As (file picker) Зберегти як (вибір файлу)+ File type Тип файлу+ Save... Зберегти...+ Binary DXF Двійковий DXF+ An export of the drawing as shown: the drawing keeps its own file. Експорт креслення як воно показане: креслення зберігає власний файл.@@ -46,6 +46,7 @@ pub mod palettes; pub mod parametric; pub mod plotstyles; pub mod quick;+pub mod saveas; pub mod shortcut_menus; pub mod theme; #[cfg(not(target_arch = "wasm32"))]@@ -159,6 +160,7 @@ impl SaveFormat { } impl Default for UiState {+ /// The interface as a first start shows it: every panel and bar on, the default save format and version. fn default() -> Self { UiState { docking: docking::default_workspace(),@@ -223,6 +225,8 @@ pub struct CadApp { pub status: Option<(String, f64)>, /// Drawings being read on worker threads ([`opening`]). pub opening: opening::Opening,+ /// The Save As dialog's choices while it is open ([`saveas`]).+ pub saveas: Option<saveas::SaveAsState>, pub integrated_titlebar: bool, control_rx: Option<Receiver<ControlRequest>>, /// Fresh surface and identity snapshot (see [`capture`]).@@ -296,6 +300,7 @@ impl CadApp { cmd: cmdline::CmdLine::default(), status: None, opening: opening::Opening::default(),+ saveas: None, integrated_titlebar: false, control_rx: None, capture_geometry: None,@@ -23,6 +23,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.open", "Open...", &[], Some("Cmd+O")), ("ui.newtemplate", "New from Template...", &["File", "New from Template..."], None), ("ui.saveas", "Save As...", &[], None),+ ("ui.saveas.pick", "Save As (file picker)", &[], None), ("ui.sample", "Open Sample Drawing", &["Help", "Open Sample Drawing"], None), ("ui.toggle.toolsets", "Tool Sets", &["Window", "Tool Sets"], Some("Cmd+3")), ("ui.panel.properties", "Properties Inspector", &["Window", "Properties Inspector"], Some("Cmd+1")),@@ -85,6 +86,8 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option<Resu crate::i18n::with_language(app.language(), || run_ui_command_inner(app, id, params)) } +/// [`run_ui_command`] in the interface language: the UI-only commands, and the file commands that+/// ask for a file (Open, Save As and its dialog, Export…) when they come without parameters. fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> { if let Some(result) = crate::spacemouse::route(app, id, params) { return Some(result);@@ -197,15 +200,25 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re } Ok(Value::Null) }+ // Save As from the menu, the tool bar, a shortcut or typed: the Save As dialog (file type,+ // version, binary DXF) before the host's file picker. "ui.saveas" | "saveas" if no_path => {+ crate::saveas::open(app);+ Ok(Value::Null)+ }+ // The picker alone, in the default format and version: QSAVE of a drawing with no file+ // yet, and the close prompt's Save, which must finish in this call.+ "ui.saveas.pick" if no_path => { let title = app.session.state().map(|s| s.title.clone()).ok(); let name = app.ui.save_format.suggested_name(title.as_deref()); if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name)) {+ // The default format is the format written, whatever extension the picker gave.+ let p = crate::saveas::with_extension(&p, app.ui.save_format.as_str()); return Some(app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string())); } Ok(Value::Null) }- "qsave" if no_path && app.session.state().is_ok_and(|s| s.path.is_none()) => return run_ui_command(app, "ui.saveas", &Value::Null),+ "qsave" if no_path && app.session.state().is_ok_and(|s| s.path.is_none()) => return run_ui_command(app, "ui.saveas.pick", &Value::Null), // EXPORTPDF from the menu or typed: ask where to save the PDF (PLOT and PRINT open the Plot // dialog, `managers::route`). JSON calls (scripts, control channel, MCP) always carry // params, never get here and never open a dialog.new file mode 100644@@ -0,0 +1,225 @@+//! The Save As dialog: the file type, the DXF/DWG version and binary DXF, chosen before the+//! host's file picker, which cannot report the type the user picked. "Save..." opens the picker+//! for the chosen type and runs `saveas` (images: an export, as SAVEAS to an image does).+//!+//! QSAVE of a drawing that has no file yet skips this dialog and goes straight to the picker+//! (`ui.saveas.pick`): the close prompt's "Save" relies on that save finishing in the same call.++use egui::{RichText, vec2};+use serde_json::{Value, json};++use crate::theme::Tokens;+use crate::{CadApp, SaveFormat, SaveVersion};++/// A file type the dialog saves as.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum FileType {+ Dxf,+ Dwg,+ Dwt,+ Svg,+ Png,+ Pdf,+}++impl FileType {+ pub const ALL: [FileType; 6] = [FileType::Dxf, FileType::Dwg, FileType::Dwt, FileType::Svg, FileType::Png, FileType::Pdf];++ /// The file extension, without the dot.+ pub fn ext(self) -> &'static str {+ match self {+ FileType::Dxf => "dxf",+ FileType::Dwg => "dwg",+ FileType::Dwt => "dwt",+ FileType::Svg => "svg",+ FileType::Png => "png",+ FileType::Pdf => "pdf",+ }+ }++ /// The name the dialog shows (translated; the format names stay as they are).+ pub fn label(self) -> &'static str {+ match self {+ FileType::Dxf => crate::tl!("Drawing (DXF)"),+ FileType::Dwg => crate::tl!("Drawing (DWG)"),+ FileType::Dwt => crate::tl!("Drawing Template (DWT)"),+ FileType::Svg => "SVG",+ FileType::Png => "PNG",+ FileType::Pdf => "PDF",+ }+ }++ /// The type of an extension.+ pub fn from_ext(ext: &str) -> Option<FileType> {+ let ext = ext.trim().trim_start_matches('.');+ FileType::ALL.into_iter().find(|t| t.ext().eq_ignore_ascii_case(ext))+ }++ /// A drawing file whose version the dialog asks for (DXF, DWG).+ fn versioned(self) -> bool {+ matches!(self, FileType::Dxf | FileType::Dwg)+ }+}++/// The dialog's choices until Save.+#[derive(Clone, Debug, PartialEq)]+pub struct SaveAsState {+ pub file_type: FileType,+ pub version: SaveVersion,+ pub binary: bool,+}++impl SaveAsState {+ /// The choices a drawing starts from: its own file type and version (an untitled drawing:+ /// the default save format and version) and its binary flag.+ pub fn for_app(app: &CadApp) -> SaveAsState {+ let st = app.session.state().ok();+ let untitled = st.as_ref().is_none_or(|s| s.path.is_none());+ let title = st.as_ref().map(|s| s.title.clone()).unwrap_or_default();+ let from_title = std::path::Path::new(&title).extension().and_then(|e| FileType::from_ext(&e.to_string_lossy()));+ let default_type = match app.ui.save_format {+ SaveFormat::Dxf => FileType::Dxf,+ SaveFormat::Dwg => FileType::Dwg,+ };+ // A drawing from a file keeps its type (a template stays a template); an untitled one,+ // or one from an image, takes the default save format.+ let file_type = from_title.filter(|t| t.versioned() || (*t == FileType::Dwt && !untitled)).unwrap_or(default_type);+ let own =+ app.session.doc().ok().and_then(|d| d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(SaveVersion::for_acadver));+ let version = match (untitled, own) {+ (false, Some(v)) => v,+ _ => SaveVersion::parse(&app.ui.save_version).unwrap_or_default(),+ };+ SaveAsState { file_type, version, binary: st.as_ref().is_some_and(|s| s.binary_dxf) && file_type == FileType::Dxf }+ }++ /// The `saveas` parameters for `path`.+ pub fn params(&self, path: &str) -> Value {+ let mut p = json!({ "path": path });+ if self.file_type.versioned()+ && let Some(o) = p.as_object_mut()+ {+ o.insert("version".into(), json!(self.version.year()));+ if self.binary && self.file_type == FileType::Dxf {+ o.insert("binary".into(), json!(true));+ }+ }+ p+ }++ /// The file name the picker suggests: the drawing's name with the chosen extension.+ pub fn suggested_name(&self, title: &str) -> String {+ let stem = std::path::Path::new(title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.trim().is_empty());+ format!("{}.{}", stem.unwrap_or_else(|| "Drawing".into()), self.file_type.ext())+ }++ /// Binary is for DXF only.+ fn settle(&mut self) {+ if self.file_type != FileType::Dxf {+ self.binary = false;+ }+ }+}++/// Open the dialog for the active drawing.+pub fn open(app: &mut CadApp) {+ app.saveas = Some(SaveAsState::for_app(app));+ app.ui.dialog = Some("saveas".into());+}++/// `path` with the chosen type's extension: kept when the picker gave it (any case), added+/// otherwise (`plan.dwg` saved as DXF is `plan.dwg.dxf`), so the type chosen is the type written.+pub(crate) fn with_extension(path: &str, ext: &str) -> String {+ let has = std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case(ext));+ if has { path.to_string() } else { format!("{path}.{ext}") }+}++/// Ask the host where to save and run `saveas` with the dialog's choices. `Some(true)` once+/// saved, `Some(false)` when the picker was cancelled or the save failed (the dialog stays),+/// `None` without a picker (said on the command line).+pub fn save(app: &mut CadApp) -> Option<bool> {+ let st = app.saveas.clone().unwrap_or_else(|| SaveAsState::for_app(app));+ let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();+ let Some(pick) = app.services.pick_save.as_ref() else {+ app.session+ .echo(r#"SAVEAS: there is no file dialog here; run it with parameters, e.g. saveas {"path": "plan.dxf", "version": 2018}"#.to_string());+ return None;+ };+ let Some(path) = pick(&st.suggested_name(&title)) else { return Some(false) };+ Some(app.run("saveas", st.params(&with_extension(&path, st.file_type.ext()))).is_ok())+}++/// A dimmed, wrapping note.+fn note(ui: &mut egui::Ui, text: &str) {+ ui.add(egui::Label::new(RichText::new(text).color(Tokens::get().text_dim)).wrap());+}++/// The Save As window: file type, version and binary DXF, then Save… (the picker) or Cancel. The+/// choices live in `app.saveas` until the window closes.+pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {+ if app.saveas.is_none() {+ app.saveas = Some(SaveAsState::for_app(app));+ }+ let Some(mut st) = app.saveas.clone() else { return };+ let (mut save_clicked, mut cancel) = (false, false);+ egui::Window::new(crate::tl!("Save As"))+ .id(egui::Id::unique("saveas_dialog"))+ .open(open)+ .collapsible(false)+ .resizable(false)+ .pivot(egui::Align2::CENTER_CENTER)+ .default_pos(ctx.content_rect().center())+ .show(ctx, |ui| {+ ui.set_max_width(440.0);+ egui::Grid::new("saveas_grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {+ ui.label(crate::tl!("File type"));+ egui::ComboBox::from_id_salt("saveas_type").selected_text(st.file_type.label()).width(260.0).show_ui(ui, |ui| {+ for ft in FileType::ALL {+ ui.selectable_value(&mut st.file_type, ft, ft.label());+ }+ });+ ui.end_row();+ if st.file_type.versioned() {+ ui.label(crate::tl!("Version"));+ egui::ComboBox::from_id_salt("saveas_version").selected_text(version_label(st.version)).width(260.0).show_ui(ui, |ui| {+ for v in SaveVersion::ALL {+ ui.selectable_value(&mut st.version, v, version_label(v));+ }+ });+ ui.end_row();+ }+ if st.file_type == FileType::Dxf {+ ui.label("");+ ui.checkbox(&mut st.binary, crate::tl!("Binary DXF"));+ ui.end_row();+ }+ });+ st.settle();+ if !st.file_type.versioned() && st.file_type != FileType::Dwt {+ note(ui, crate::tl!("An export of the drawing as shown: the drawing keeps its own file."));+ }+ ui.separator();+ ui.horizontal(|ui| {+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+ save_clicked = ui.button(crate::tl!("Save...")).clicked();+ cancel = ui.button(crate::tl!("Cancel")).clicked();+ });+ });+ });+ app.saveas = Some(st);+ // Closed once saved, or when there is no picker to ask (the command line says how to save).+ if cancel || (save_clicked && save(app) != Some(false)) {+ *open = false;+ }+ if !*open {+ app.saveas = None;+ }+}++/// A version's label: "AutoCAD 2018".+pub fn version_label(v: SaveVersion) -> String {+ format!("AutoCAD {}", v.year())+}++#[cfg(test)]+mod tests;new file mode 100644@@ -0,0 +1,119 @@+use std::cell::RefCell;+use std::rc::Rc;++use cadcraft_engine::Session;+use serde_json::json;++use super::*;+use crate::Services;++/// An app whose file picker answers `answer` and records every name it was offered.+fn app_with_picker(answer: Option<String>) -> (CadApp, Rc<RefCell<Vec<String>>>) {+ let asked = Rc::new(RefCell::new(Vec::new()));+ let log = Rc::clone(&asked);+ let services = Services {+ pick_open: None,+ pick_save: Some(Box::new(move |name: &str| {+ log.borrow_mut().push(name.to_string());+ answer.clone()+ })),+ ..Services::default()+ };+ (CadApp::new(Session::new(), services), asked)+}++/// Save As opens the dialog, not the picker, on the drawing's own type and version (a template+/// stays a template), and its choices become the `saveas` parameters.+#[test]+fn save_as_opens_the_dialog_with_the_drawings_own_type_and_version() {+ let (mut app, asked) = app_with_picker(Some("x.dxf".into()));+ app.start("ui.saveas");+ assert_eq!(app.ui.dialog.as_deref(), Some("saveas"));+ assert!(asked.borrow().is_empty(), "the picker waits for Save...");+ let st = app.saveas.clone().unwrap();+ assert_eq!((st.file_type, st.version, st.binary), (FileType::Dxf, SaveVersion::R2018, false));+ // A drawing opened as a 2000 DWG.+ let mut d = cadcraft_doc::Drawing::new_metric();+ d.header.set_str("ACADVER", "AC1015");+ app.session.open_drawing(d, "plan.dwg", Some("/tmp/plan.dwg".into()));+ open(&mut app);+ let st = app.saveas.clone().unwrap();+ assert_eq!((st.file_type, st.version), (FileType::Dwg, SaveVersion::R2000));+ assert_eq!(st.suggested_name("plan.dwg"), "plan.dwg");+ assert_eq!(st.params("/tmp/plan.dwg"), json!({ "path": "/tmp/plan.dwg", "version": "2000" }));+ // A template from a file stays a template.+ app.session.open_drawing(cadcraft_doc::Drawing::new_metric(), "base.dwt", Some("/tmp/base.dwt".into()));+ open(&mut app);+ assert_eq!(app.saveas.as_ref().map(|s| s.file_type), Some(FileType::Dwt));+}++/// Choices that don't go together settle (binary is for DXF), images and templates carry no+/// version, and the chosen extension is always the one written.+#[test]+fn choices_settle_and_become_saveas_parameters() {+ let mut st = SaveAsState { file_type: FileType::Dwg, version: SaveVersion::R2004, binary: true };+ st.settle();+ assert!(!st.binary, "binary is for DXF");+ assert_eq!(st.params("a.dwg"), json!({ "path": "a.dwg", "version": "2004" }));+ // Images and templates carry no version.+ let st = SaveAsState { file_type: FileType::Pdf, version: SaveVersion::R2018, binary: true };+ assert_eq!(st.params("a.pdf"), json!({ "path": "a.pdf" }));+ let st = SaveAsState { file_type: FileType::Dwt, ..st };+ assert_eq!(st.params("a.dwt"), json!({ "path": "a.dwt" }));+ let st = SaveAsState { file_type: FileType::Dxf, version: SaveVersion::R2004, binary: true };+ assert_eq!(st.params("a.dxf"), json!({ "path": "a.dxf", "version": "2004", "binary": true }));+ assert_eq!(st.suggested_name("Plan.dwg"), "Plan.dxf");+ assert_eq!(st.suggested_name(""), "Drawing.dxf");+ assert_eq!(FileType::from_ext(".DWG"), Some(FileType::Dwg));+ assert_eq!(with_extension("/tmp/plan", "dxf"), "/tmp/plan.dxf");+ assert_eq!(with_extension("/tmp/plan.DXF", "dxf"), "/tmp/plan.DXF");+ assert_eq!(with_extension("/tmp/plan.dwg", "dxf"), "/tmp/plan.dwg.dxf", "the chosen type is the type written");+ assert!(SaveVersion::ALL.iter().all(|v| version_label(*v).starts_with("AutoCAD ")));+}++/// Save… offers the picker a name with the chosen extension and keeps the dialog when it is+/// cancelled; without a picker the command line says how to save.+#[test]+fn save_asks_the_picker_with_the_chosen_type_and_keeps_the_dialog_when_cancelled() {+ let (mut app, asked) = app_with_picker(None);+ open(&mut app);+ if let Some(st) = app.saveas.as_mut() {+ st.file_type = FileType::Svg;+ }+ assert_eq!(save(&mut app), Some(false));+ assert_eq!(*asked.borrow(), vec!["Drawing1.svg".to_string()]);+ // Without a picker the command line says how to save.+ let mut app = CadApp::new(Session::new(), Services::default());+ assert_eq!(save(&mut app), None);+ assert!(app.session.log.iter().any(|l| l.contains("no file dialog")));+}++/// QSAVE of a drawing with no file skips the dialog: the close prompt's Save must finish at once.+#[test]+fn qsave_of_an_untitled_drawing_goes_straight_to_the_picker() {+ let (mut app, asked) = app_with_picker(None);+ app.start("qsave");+ assert_eq!(*asked.borrow(), vec!["Drawing1.dxf".to_string()]);+ assert!(app.ui.dialog.is_none());+}++/// The dialog draws for every file type and keeps the choice across frames.+#[test]+fn the_dialog_renders_its_choices() {+ let (mut app, _) = app_with_picker(None);+ for ft in FileType::ALL {+ open(&mut app);+ if let Some(st) = app.saveas.as_mut() {+ st.file_type = ft;+ }+ let ctx = egui::Context::default();+ let input =+ egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };+ for _ in 0..2 {+ let mut output = ctx.run_ui(input.clone(), |ui| app.ui(ui));+ output.textures_delta.clear();+ }+ assert_eq!(app.ui.dialog.as_deref(), Some("saveas"), "{ft:?}");+ assert_eq!(app.saveas.as_ref().map(|s| s.file_type), Some(ft));+ }+}@@ -39,6 +39,7 @@ fn drafting_settings_menu_opens_the_dialog() { assert_eq!(app.session.settings.gridmajor, 10); } +/// The Properties menu item shows the Properties palette. #[test] fn properties_menu_shows_the_palette() { let (mut app, _) = app();@@ -49,6 +50,8 @@ fn properties_menu_shows_the_palette() { assert!(app.ui.show_palettes); } +/// File commands with JSON parameters never open a picker; typed or from a menu they ask, Save As+/// with its dialog first and QSAVE of an untitled drawing with the picker straight away. #[test] fn json_file_commands_never_open_pickers() { let (mut app, picks) = app();@@ -57,14 +60,18 @@ fn json_file_commands_never_open_pickers() { } app.cmdline("open {}"); assert_eq!(picks.get(), 0);- // Typed, chosen from the menu or the ui.* shortcut commands: the pickers.+ // Typed, chosen from the menu or the ui.* shortcut commands: the pickers — Save As first+ // its dialog (file type, version), QSAVE of an untitled drawing the picker straight away. for c in ["open", "saveas", "qsave"] { app.start(c); }- assert_eq!(picks.get(), 3);+ assert_eq!(picks.get(), 2);+ assert_eq!(app.ui.dialog.as_deref(), Some("saveas"));+ app.ui.dialog = None; app.run("ui.open", json!({})).unwrap(); app.run("ui.saveas", json!({})).unwrap();- assert_eq!(picks.get(), 5);+ assert_eq!(picks.get(), 3);+ assert_eq!(app.ui.dialog.as_deref(), Some("saveas")); } #[test]@@ -11,10 +11,13 @@ fn picked() -> std::path::PathBuf { std::env::temp_dir().join(format!("cadcraft-ui-save-version-{}.dxf", std::process::id())) } +/// A writer that records the version and binary flag it was asked for instead of a drawing. fn with_options(_: &Drawing, _: &str, o: &SaveAs) -> Result<Vec<u8>, String> { Ok(format!("{}|{}", o.version.map(|v| v.year()).unwrap_or("-"), o.binary).into_bytes()) } +/// The default save version is offered to untitled drawings by the Save As dialog, written by its+/// Save… and by an untitled QSAVE (in the default format), and kept in the preferences. #[test] fn default_save_version_applies_to_new_drawings_and_persists() { set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"plain".to_vec()), plot: None });@@ -25,14 +28,28 @@ fn default_save_version_applies_to_new_drawings_and_persists() { assert!(app.run("ui.saveversion", json!({ "version": "2005" })).is_err()); assert_eq!(app.run("ui.saveversion", json!({ "version": 2010 })).ok(), Some(json!({ "saveVersion": "2010" }))); - // The Save dialog saves an untitled drawing in the default version …+ // The Save As dialog offers an untitled drawing's default version, and its Save... runs the+ // picker and saves in it … app.run("ui.saveas", Value::Null).unwrap();+ assert_eq!(app.ui.dialog.as_deref(), Some("saveas"));+ assert_eq!(cadcraft_ui_egui::saveas::save(&mut app), Some(true)); assert_eq!(std::fs::read(picked()).unwrap(), b"2010|false");- // … and a drawing that has a file in its own.+ // … and a drawing that has a file starts from its own version. app.run("ui.saveas", Value::Null).unwrap();- assert_eq!(std::fs::read(picked()).unwrap(), b"plain");+ assert_eq!(cadcraft_ui_egui::saveas::save(&mut app), Some(true));+ assert_eq!(std::fs::read(picked()).unwrap(), b"2010|false"); let _ = std::fs::remove_file(picked()); + // QSAVE of an untitled drawing (the picker alone): a name picked with another extension+ // gets the default format's, so DXF is what is written.+ let dwg = std::env::temp_dir().join(format!("cadcraft-ui-save-version-{}.dwg", std::process::id()));+ let services = Services { pick_save: Some(Box::new(move |_: &str| Some(dwg.to_string_lossy().to_string()))), ..Services::default() };+ let mut untitled = CadApp::new(Session::new(), services);+ untitled.start("qsave");+ let written = std::env::temp_dir().join(format!("cadcraft-ui-save-version-{}.dwg.dxf", std::process::id()));+ assert!(written.exists(), "saved as {written:?}");+ let _ = std::fs::remove_file(&written);+ // Menu items, preferences (bad values ignored) and resetting the palettes. app.start("ui.saveversion.2004"); assert_eq!(app.ui.save_version, "2004");@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (the Save As dialog picks the version per file: FF3 updated) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -75,7 +75,7 @@ Detail: [file-format-parity.md](file-format-parity.md). |---|---|---|---| | FF1 | **[alpha blocker]** Round-trip what we don't model. DXF done (#528): unknown entities and objects (with their CLASS records), foreign xdata (hyperlinks included), extension dictionaries, persistent reactors, DICTIONARYVAR and XRECORDs, layer states, colour book colours, references remapped on save; IMAGE, VIEW/UCS, groups, block descriptions, true colours, transparency (#201, #204, #208, #209, #213, #223); WIPEOUT and gradients (#147, #162). Left: through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour book names and reactors; UCS elevation; MTEXT background colours; unknown entities follow only layer edits, and renamed layers and blocks lose their foreign data | High | 6–10 | | FF2 | Multileader leftovers (native MULTILEADER read/write landed, #459): several landings per multileader (extra leader roots are folded into the lines), per-object overrides (leader colour and linetype, arrowhead symbol, text frame), annotative scale contexts; check the text placement of our MULTILEADER output in AutoCAD | Medium | 3–5 |-| FF3 | Save-version leftovers (DXF/DWG 2000–2018, binary DXF, SAVEAS `version`/`binary` and the default save version landed, #421): no R14 DWG or R12 DXF output; the Save dialog can't pick the version per file (the file picker doesn't report the chosen type; File > Default Save Format sets it); no command-line SAVEAS format prompt | Low | 2–4 |+| FF3 | Save-version leftovers (DXF/DWG 2000–2018, binary DXF, SAVEAS `version`/`binary` and the default save version landed, #421; the Save As dialog picks the file type, version and binary DXF per file before the picker): no R14 DWG or R12 DXF output; no command-line SAVEAS format prompt | Low | 1–3 | | FF4 | Code-page leftovers (pre-2007 DXF read in their `$DWGCODEPAGE` landed, #505): Johab (ANSI_1361) and DOS 855/857/860–865/869 read as ANSI_1252; saving declares `ANSI_1252` instead of the file's code page (the text is pure ASCII, so still correct); acadrust approximates DOS code pages in pre-2007 DWG (DOS437 as 866, DOS850 as 1252) | Low | 1–2 | | FF5 | **[alpha blocker]** Real-file corpus (`storytold/cadcraft-corpus`), open → save → reopen tests, black-box check in AutoCAD | High | 10–15 + human | | FF6 | **[alpha blocker]** DWG throughput: a 10 MB DWG took ~2 min to fail (#57) | High for real projects | 6–10 |@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Save As is a dialog: file type (DXF, DWG, DWT, SVG, PNG, PDF), version and binary DXF chosen per file before the file picker, which can't report them: FF3 updated | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |