From 7f41e1666f66f9a62a82c5d87abaa17baf96c9ee Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 27 Jul 2026 22:37:47 +0300 Subject: [PATCH] feat(options): set default save format Avoid false data-loss warnings when preserving the source format.\n\nCloses #529 --- src/app/mod.rs | 10 +++- src/app/settings.rs | 4 ++ src/app/update/dialog.rs | 36 +++++++++------ src/app/update/file.rs | 38 ++++++++++++---- src/app/update/mod.rs | 13 +++++- src/app/view/mod.rs | 2 + src/app/view/modal.rs | 37 +++++++++++---- src/io/mod.rs | 37 +++++++-------- src/ui/window/mod.rs | 1 + src/ui/window/options.rs | 98 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 223 insertions(+), 53 deletions(-) create mode 100644 src/ui/window/options.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index b272b879f..247cd32d2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -789,6 +789,8 @@ pub(super) struct OpenCADStudio { save_dialog_filename: String, /// True when triggered from the unsaved-changes flow. save_dialog_for_unsaved: bool, + /// User preference for the first save of a new/unsaved drawing. + default_save_format: String, // ── DimStyle Dialog ─────────────────────────────────────────────────── /// Name of the style currently shown in the dialog. @@ -1298,6 +1300,7 @@ pub enum ModalKind { DimStyle, Unsaved, SaveDialog, + Options, AecDropWarning, #[cfg(not(target_arch = "wasm32"))] FileInUse, @@ -1468,6 +1471,10 @@ pub enum Message { SaveDialogCancel, /// Destination picked in the native OS save dialog (`None` = cancelled). SaveDialogPathPicked(Option), + /// Open the application-wide Options dialog. + OptionsOpen, + /// Set the default type/version used when first saving a new drawing. + DefaultSaveFormatChanged(String), ClearScene, SetWireframe(bool), /// Set the active tab's render mode (one of acadrust's seven visual @@ -2618,9 +2625,10 @@ impl OpenCADStudio { pending_save_failure: None, #[cfg(not(target_arch = "wasm32"))] pending_external_change: None, - save_dialog_format: "DWG 2018".to_string(), + save_dialog_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), save_dialog_filename: "drawing.dwg".to_string(), save_dialog_for_unsaved: false, + default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), // Plot style active_plot_style: None, // Color scheme (default: dark CAD-style) diff --git a/src/app/settings.rs b/src/app/settings.rs index 2461e5130..f941d33cc 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -135,6 +135,9 @@ pub struct UserSettings { /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, + /// File type and version used when a new/unsaved drawing is first saved. + /// Existing drawings keep their own type and version. + pub default_save_format: String, /// PICKADD (#226): `true` (default) = a plain click ADDS to the selection /// (Shift removes); `false` = OS-style — a click REPLACES the selection /// and Shift+click toggles membership. @@ -169,6 +172,7 @@ impl Default for UserSettings { backup_on_save: true, file_assoc_enabled: true, savetime_min: 10, + default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, pick_drag_rect: false, bg_color: None, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 9792ecf58..95689de59 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -26,18 +26,24 @@ impl OpenCADStudio { // Default the format dropdown to the loaded file's own format — its // DWG-vs-DXF kind (from the extension) and its version (from the parsed // document) — so Save-As round-trips the format instead of silently - // re-targeting the latest. A new/unsaved drawing has no path, so it - // keeps its document default version and DWG. The user can still pick - // any other version explicitly. - let is_dxf = self.tabs[tab_idx] - .current_path - .as_ref() - .and_then(|p| p.extension()) - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("dxf")) - .unwrap_or(false); - let version = self.tabs[tab_idx].scene.document.version; - self.save_dialog_format = crate::io::format_for_version(version, is_dxf); + // re-targeting it. A new/unsaved drawing has no source format, so it + // uses the application-wide default chosen in Options (#529). + self.save_dialog_format = if let Some(path) = &self.tabs[tab_idx].current_path { + let is_dxf = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("dxf")) + .unwrap_or(false); + let document = &self.tabs[tab_idx].scene.document; + let version = if is_dxf { + document.version + } else { + document.dwg_source_version.unwrap_or(document.version) + }; + crate::io::format_for_version(version, is_dxf) + } else { + self.default_save_format.clone() + }; // Pre-fill the default file name from the current path or the tab name; // the destination folder comes from the native OS dialog that follows. @@ -280,7 +286,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.active_tab = idx; self.save_dialog_for_unsaved = true; let close = self.close_unsaved_dialog_window(); - let save = self.save_default_dwg2018(idx); + let save = self.save_with_default_format(idx); return Task::batch([close, save]); } @@ -291,7 +297,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.pending_close = Some(crate::app::PendingClose::Tab(idx)); self.save_dialog_for_unsaved = true; let close = self.close_unsaved_dialog_window(); - let save = self.save_default_dwg2018(idx); + let save = self.save_with_default_format(idx); Task::batch([close, save]) } Some(crate::app::PendingClose::Quit) => { @@ -300,7 +306,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.pending_close = Some(crate::app::PendingClose::Quit); self.save_dialog_for_unsaved = true; let close = self.close_unsaved_dialog_window(); - let save = self.save_default_dwg2018(idx); + let save = self.save_with_default_format(idx); Task::batch([close, save]) } else { Task::batch([ diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 56439debf..722e613fb 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -118,6 +118,7 @@ impl OpenCADStudio { backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, savetime_min: self.savetime_min, + default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, pick_drag_rect: self.pick_drag_rect, bg_color: self.default_bg_color.map(f4_to_u3), @@ -145,6 +146,8 @@ impl OpenCADStudio { self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; self.savetime_min = s.savetime_min; + self.default_save_format = + crate::io::canonical_save_format(&s.default_save_format).to_string(); self.pick_add = s.pick_add; self.pick_drag_rect = s.pick_drag_rect; self.default_bg_color = s.bg_color.map(u3_to_f4); @@ -1307,6 +1310,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } if outcome.set_current_path { self.tabs[i].current_path = Some(outcome.path.clone()); + self.tabs[i].scene.document.version = outcome.version; tasks.push(self.push_recent(outcome.path.clone())); } self.refresh_native_edit_guard_after_save( @@ -1528,22 +1532,23 @@ pub(super) fn on_open_file(&mut self) -> Task { ); } self.save_dialog_for_unsaved = false; - self.save_default_dwg2018(i) + self.save_with_default_format(i) } - /// Save without the version picker: default to DWG 2018 and go straight to + /// Save without the version picker: use the configured default and go straight to /// the native destination dialog (native) or the browser download (web). /// Used by plain Save (QSAVE) on an as-yet-unsaved drawing and by the /// save-before-close flow — the version picker is reserved for Save As. - pub(in crate::app) fn save_default_dwg2018(&mut self, tab_idx: usize) -> Task { + pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task { self.active_tab = tab_idx; - self.save_dialog_format = "DWG 2018".to_string(); + self.save_dialog_format = self.default_save_format.clone(); + let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format); self.save_dialog_filename = self.tabs[tab_idx] .current_path .as_ref() .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| format!("{}.dwg", self.tabs[tab_idx].tab_display_name())); + .unwrap_or_else(|| format!("{}.{ext}", self.tabs[tab_idx].tab_display_name())); self.aec_drop_acknowledged = false; self.on_save_dialog_confirm() } @@ -1625,6 +1630,7 @@ pub(super) fn on_open_file(&mut self) -> Task { match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) { Ok(bytes) => { crate::sys::download_bytes(&filename, &bytes); + self.tabs[i].scene.document.version = version; self.tabs[i].dirty = false; self.command_line.push_output(&format!("Saved: {filename}")); true @@ -1700,12 +1706,24 @@ pub(super) fn on_open_file(&mut self) -> Task { } /// AEC-drop warning → "Save in source version": switch the target to the - /// document's own DWG version (where the unsupported objects round-trip as - /// verbatim bytes), then save. + /// document's source type and version, then save. pub(super) fn on_aec_drop_same_version(&mut self) -> Task { - let src = self.tabs[self.active_tab].scene.document.version; - self.save_dialog_format = crate::io::format_for_version(src, false); - // Strip any extension (e.g. .dxf) so the confirm path appends .dwg. + let tab = &self.tabs[self.active_tab]; + let is_dxf = tab + .current_path + .as_ref() + .and_then(|path| path.extension()) + .and_then(|extension| extension.to_str()) + .map(|extension| extension.eq_ignore_ascii_case("dxf")) + .unwrap_or(false); + let document = &tab.scene.document; + let src = if is_dxf { + document.version + } else { + document.dwg_source_version.unwrap_or(document.version) + }; + self.save_dialog_format = crate::io::format_for_version(src, is_dxf); + // Strip the old extension so the confirm path appends the source type. let stem = std::path::Path::new(&self.save_dialog_filename) .file_stem() .map(|s| s.to_string_lossy().into_owned()) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9a1979f7e..9a8e832ca 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3663,7 +3663,18 @@ impl OpenCADStudio { Task::none() } - // ── About window ────────────────────────────────────────────── + // ── Options / About windows ─────────────────────────────────── + Message::OptionsOpen => { + self.active_modal = Some(super::ModalKind::Options); + Task::none() + } + + Message::DefaultSaveFormatChanged(format) => { + self.default_save_format = + crate::io::canonical_save_format(&format).to_string(); + Task::none() + } + Message::AboutOpen => { self.active_modal = Some(super::ModalKind::About); Task::none() diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index cf32e4ba0..66673a065 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1708,6 +1708,7 @@ impl OpenCADStudio { let (w, h) = match self.active_modal? { About => (440, 360), Shortcuts => (720, 520), + Options => (480, 190), PluginManager => (520, 460), UpdateNotice => (560, 460), Layers => (900, 360), @@ -2524,6 +2525,7 @@ pub(super) fn start_page_view<'a>( }, ) .into(), + outline_btn("Options", Message::OptionsOpen).into(), outline_btn("Plugins", Message::PluginManagerOpen).into(), ]; // The web build is already in the browser, so only the desktop offers a diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 80ebb09ff..45a2eea02 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -11,6 +11,7 @@ impl OpenCADStudio { Some(K::About) => "About", Some(K::Shortcuts) => "Keyboard Shortcuts", Some(K::Aliases) => "Command Aliases", + Some(K::Options) => "Options", Some(K::PluginManager) => "Plugin Manager", Some(K::UpdateNotice) => "Update Available", Some(K::Layers) => "Layer Manager", @@ -61,6 +62,11 @@ impl OpenCADStudio { super::super::ModalKind::Aliases => { sized(crate::ui::window::alias_editor::view_window(&self.alias_editor_rows), 480, 520) } + super::super::ModalKind::Options => sized( + crate::ui::window::options::view_window(&self.default_save_format), + 480, + 190, + ), super::super::ModalKind::PluginManager => sized( crate::ui::window::plugin_manager::view_window( &self.disabled_plugins, @@ -574,7 +580,24 @@ impl OpenCADStudio { let src_label = self .tabs .get(self.active_tab) - .map(|t| crate::io::format_for_version(t.scene.document.version, false)) + .map(|t| { + let is_dxf = t + .current_path + .as_ref() + .and_then(|path| path.extension()) + .and_then(|extension| extension.to_str()) + .map(|extension| extension.eq_ignore_ascii_case("dxf")) + .unwrap_or(false); + let version = if is_dxf { + t.scene.document.version + } else { + t.scene + .document + .dwg_source_version + .unwrap_or(t.scene.document.version) + }; + crate::io::format_for_version(version, is_dxf) + }) .unwrap_or_else(|| "DWG".to_string()); sized( aec_drop_dialog_window( @@ -689,11 +712,6 @@ impl OpenCADStudio { } } -const SAVE_FORMAT_OPTIONS: &[&str] = &[ - "DWG 2018", "DWG 2013", "DWG 2010", "DWG 2007", "DWG 2004", "DWG 2000", "DWG R14", "DXF 2018", - "DXF 2013", "DXF 2010", "DXF 2007", "DXF 2004", "DXF 2000", "DXF R14", -]; - /// Compact Save-As options dialog: pick the format/version and a default file /// name. The destination folder and overwrite confirmation come from the /// native OS save dialog (native) or the browser download (web) that follows. @@ -769,7 +787,10 @@ fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, .padding([4, 12]) }; - let sel_fmt = SAVE_FORMAT_OPTIONS.iter().copied().find(|&s| s == format); + let sel_fmt = crate::io::SAVE_FORMAT_OPTIONS + .iter() + .copied() + .find(|&s| s == format); let label = |s: &'static str| text(s).size(11).color(DIM); let mut items: Vec> = Vec::new(); @@ -826,7 +847,7 @@ fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, items.push( row![ label("Format:").width(70), - pick_list(SAVE_FORMAT_OPTIONS, sel_fmt, |s: &str| { + pick_list(crate::io::SAVE_FORMAT_OPTIONS, sel_fmt, |s: &str| { Message::SaveDialogFormatChanged(s.to_string()) }) .width(Fill), diff --git a/src/io/mod.rs b/src/io/mod.rs index 4f3fb5c23..c47841e64 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -432,6 +432,21 @@ pub(crate) fn resolve_image_file(raw: &str, base_dir: Option<&Path>) -> Option &'static str { + SAVE_FORMAT_OPTIONS + .iter() + .copied() + .find(|candidate| candidate.eq_ignore_ascii_case(format)) + .unwrap_or(DEFAULT_SAVE_FORMAT) +} + /// Parse a format string like "DWG 2013" or "DXF 2007" into /// `(extension, DxfVersion)`. Falls back to ("dwg", AC1032) for unknown strings. pub fn parse_save_format(format: &str) -> (&'static str, acadrust::DxfVersion) { @@ -486,6 +501,10 @@ pub fn dropped_on_save_count( target_version: acadrust::DxfVersion, is_dxf: bool, ) -> usize { + if !is_dxf && doc.dwg_source_version == Some(target_version) { + return 0; + } + let mut n = doc .objects .values() @@ -503,10 +522,6 @@ pub fn dropped_on_save_count( || raw_dwg_version.is_some_and(|source| source != target_version) } } - acadrust::objects::ObjectType::GeoData(_) - | acadrust::objects::ObjectType::VisualStyle(_) - | acadrust::objects::ObjectType::Material(_) - | acadrust::objects::ObjectType::TableStyle(_) => !is_dxf, _ => false, }) .count(); @@ -522,20 +537,6 @@ pub fn dropped_on_save_count( .is_some_and(|source| source != target_version) } } - acadrust::EntityType::Surface(entity) => { - is_dxf - || entity.raw_dwg_data.is_none() - || entity - .dwg_source_version - .is_some_and(|source| source != target_version) - } - acadrust::EntityType::Light(entity) => { - is_dxf - || entity.raw_dwg_data.is_none() - || entity - .dwg_source_version - .is_some_and(|source| source != target_version) - } _ => false, }; if dropped { diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index f59efdc8e..205e81a13 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -6,5 +6,6 @@ pub mod shortcuts; pub mod layers; pub mod update_notice; pub mod open_progress; +pub mod options; pub mod attribute_editor; pub mod alias_editor; diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs new file mode 100644 index 000000000..1e68316d8 --- /dev/null +++ b/src/ui/window/options.rs @@ -0,0 +1,98 @@ +use crate::app::Message; +use iced::widget::{button, column, container, pick_list, row, text, Space}; +use iced::{Background, Border, Color, Element, Fill, Theme}; + +pub fn view_window<'a>(default_save_format: &'a str) -> Element<'a, Message> { + const BG: Color = Color { + r: 0.15, + g: 0.15, + b: 0.17, + a: 1.0, + }; + const BORDER: Color = Color { + r: 0.32, + g: 0.32, + b: 0.36, + a: 1.0, + }; + const TEXT: Color = Color { + r: 0.90, + g: 0.90, + b: 0.90, + a: 1.0, + }; + const DIM: Color = Color { + r: 0.60, + g: 0.60, + b: 0.64, + a: 1.0, + }; + + let selected = crate::io::SAVE_FORMAT_OPTIONS + .iter() + .copied() + .find(|candidate| *candidate == default_save_format); + + let close = button(text("Close").size(12).color(TEXT)) + .on_press(Message::CloseModal) + .padding([5, 16]) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => Color { + r: 0.34, + g: 0.34, + b: 0.38, + a: 1.0, + }, + _ => Color { + r: 0.26, + g: 0.26, + b: 0.29, + a: 1.0, + }, + })), + text_color: TEXT, + border: Border { + color: BORDER, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }); + + let body = column![ + text("Open and Save").size(15).color(TEXT), + Space::new().height(16), + row![ + text("Default save format:").size(12).color(TEXT).width(150), + pick_list(crate::io::SAVE_FORMAT_OPTIONS, selected, |format: &str| { + Message::DefaultSaveFormatChanged(format.to_string()) + }) + .width(Fill), + ] + .spacing(12) + .align_y(iced::Center), + Space::new().height(10), + text( + "Used when a new drawing is saved for the first time. Existing drawings keep their current file type and version." + ) + .size(11) + .color(DIM) + .width(Fill), + Space::new().height(Fill), + row![Space::new().width(Fill), close], + ] + .spacing(0) + .width(Fill) + .height(Fill); + + container(body) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .padding([16, 18]) + .width(Fill) + .height(Fill) + .into() +}