From b77187e67222f6b1160b5d343b09e60be3ef781d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 29 Jun 2026 11:47:37 +0300 Subject: [PATCH] feat(save): warn before dropping AEC/Civil objects or overwriting on Save-As Two in-canvas confirmations on the Save-As path: - AEC/Civil drop warning: when the drawing carries unsupported AEC/Civil (application) objects that survive only as verbatim source-version bytes, saving to a different version or to DXF would silently drop them. The dialog reports the count and offers "Save in source version" (keep) vs "Save anyway" (drop). Same-version DWG saves are lossless and skip it. - Overwrite confirmation: when the chosen filename already exists in the target folder, "Replace" / "Cancel" before clobbering it. The two chain (AEC first, then overwrite) via per-flow acknowledgement flags reset whenever the Save dialog opens or closes. Detection is a new io::dropped_on_save_count over the document's Unknown (raw-passthrough) objects/entities. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 24 +++++++ src/app/update/dialog.rs | 4 ++ src/app/update/file.rs | 58 ++++++++++++++++- src/app/update/mod.rs | 13 ++++ src/app/view/mod.rs | 2 + src/app/view/modal.rs | 136 +++++++++++++++++++++++++++++++++++++++ src/io/mod.rs | 31 +++++++++ 7 files changed, 267 insertions(+), 1 deletion(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index cd6d6ea06..1167c082d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -332,6 +332,13 @@ pub(super) struct OpenCADStudio { /// The open in-canvas modal dialog, if any (Plan B: shared overlay instead /// of OS windows). active_modal: Option, + /// Set once the user acknowledges the AEC-drop warning, so re-entering the + /// save path proceeds instead of re-showing the warning. + aec_drop_acknowledged: bool, + /// Number of unsupported objects shown in the AEC-drop warning modal. + aec_drop_count: usize, + /// Set once the user confirms overwriting an existing file on Save-As. + overwrite_acknowledged: bool, /// Pixel offset of the active modal from screen-centre (drag-to-move). /// Reset to zero whenever a modal closes so each dialog opens centred. modal_offset: iced::Vector, @@ -923,6 +930,8 @@ pub enum ModalKind { DimStyle, Unsaved, SaveDialog, + AecDropWarning, + OverwriteWarning, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] AssocPrompt, PointStyle, @@ -1093,6 +1102,18 @@ pub enum Message { UnsavedDialogDiscard, /// User clicked "Cancel" in the unsaved-changes dialog. UnsavedDialogCancel, + // ── AEC / unsupported-object drop warning (lossy Save-As) ────────────── + /// Save as the source DWG version so the unsupported objects survive. + AecDropSameVersion, + /// Proceed with the chosen target format, dropping the unsupported objects. + AecDropProceed, + /// Go back to the Save dialog from the AEC-drop warning. + AecDropBack, + // ── Overwrite confirmation (Save-As over an existing file) ──────────── + /// Replace the existing file and save. + OverwriteConfirm, + /// Go back to the Save dialog from the overwrite warning. + OverwriteCancel, /// Save-as path picked for the unsaved-changes → save → close flow. UnsavedPickedSavePath(Option), // ───────────────────────────────────────────────────────────────────── @@ -1868,6 +1889,9 @@ impl OpenCADStudio { main_window: None, color_pick_target: None, active_modal: None, + aec_drop_acknowledged: false, + aec_drop_count: 0, + overwrite_acknowledged: false, modal_offset: iced::Vector::ZERO, modal_drag_last: None, modal_dragging: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index b17cda167..fc2b96079 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -49,12 +49,16 @@ impl OpenCADStudio { self.save_dialog_filename = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name()); } self.save_dialog_entries = crate::io::read_dir_entries(&self.save_dialog_folder.clone()); + self.aec_drop_acknowledged = false; + self.overwrite_acknowledged = false; self.active_modal = Some(crate::app::ModalKind::SaveDialog); Task::none() } pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task { + self.aec_drop_acknowledged = false; + self.overwrite_acknowledged = false; if self.active_modal == Some(crate::app::ModalKind::SaveDialog) { self.active_modal = None; } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 048fefde8..c13b408d6 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -552,6 +552,23 @@ pub(super) fn on_open_file(&mut self) -> Task { pub(super) fn on_save_dialog_confirm(&mut self) -> Task { let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format); + // Warn before a lossy Save-As that would drop unsupported + // (AEC / application) objects kept only as verbatim + // source-version bytes — let the user keep them by saving in the + // source version, or proceed and drop them. + if !self.aec_drop_acknowledged { + let is_dxf = ext.eq_ignore_ascii_case("dxf"); + let n = crate::io::dropped_on_save_count( + &self.tabs[self.active_tab].scene.document, + version, + is_dxf, + ); + if n > 0 { + self.aec_drop_count = n; + self.active_modal = Some(crate::app::ModalKind::AecDropWarning); + return Task::none(); + } + } // The user need not type an extension: append the selected // format's one when the entered name carries none. let name = self.save_dialog_filename.trim(); @@ -563,7 +580,14 @@ pub(super) fn on_open_file(&mut self) -> Task { name.to_string() }; self.save_dialog_filename = filename.clone(); - self.save_dialog_filename = filename.clone(); + // Confirm before overwriting an existing file in the folder. + #[cfg(not(target_arch = "wasm32"))] + if !self.overwrite_acknowledged + && self.save_dialog_folder.join(&filename).exists() + { + self.active_modal = Some(crate::app::ModalKind::OverwriteWarning); + return Task::none(); + } let close = self.close_save_dialog_window(); let i = self.active_tab; sync_annotation_scale_header(&mut self.tabs[i].scene); @@ -609,6 +633,38 @@ pub(super) fn on_open_file(&mut self) -> Task { } } + /// AEC-drop warning → "Save anyway": accept the loss and proceed with the + /// format the user already chose. + pub(super) fn on_aec_drop_proceed(&mut self) -> Task { + self.aec_drop_acknowledged = true; + self.active_modal = Some(crate::app::ModalKind::SaveDialog); + self.on_save_dialog_confirm() + } + + /// 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. + 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 stem = std::path::Path::new(&self.save_dialog_filename) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.save_dialog_filename.clone()); + self.save_dialog_filename = stem; + self.aec_drop_acknowledged = true; + self.active_modal = Some(crate::app::ModalKind::SaveDialog); + self.on_save_dialog_confirm() + } + + /// Overwrite warning → "Replace": save over the existing file. + pub(super) fn on_overwrite_confirm(&mut self) -> Task { + self.overwrite_acknowledged = true; + self.active_modal = Some(crate::app::ModalKind::SaveDialog); + self.on_save_dialog_confirm() + } + pub(super) fn on_page_setup_commit(&mut self) -> Task { let i = self.active_tab; let layout_name = self.tabs[i].scene.current_layout.clone(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index f75ecf66b..803d09c58 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2527,6 +2527,19 @@ impl OpenCADStudio { Message::UnsavedDialogSave => self.on_unsaved_dialog_save(), + Message::AecDropSameVersion => self.on_aec_drop_same_version(), + Message::AecDropProceed => self.on_aec_drop_proceed(), + Message::AecDropBack => { + self.active_modal = Some(crate::app::ModalKind::SaveDialog); + Task::none() + } + + Message::OverwriteConfirm => self.on_overwrite_confirm(), + Message::OverwriteCancel => { + self.active_modal = Some(crate::app::ModalKind::SaveDialog); + Task::none() + } + Message::UnsavedPickedSavePath(Some(path)) => { self.on_unsaved_picked_save_path_some(path) } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 2652341ae..642db3e54 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1358,6 +1358,8 @@ impl OpenCADStudio { DimStyle => (720, 560), AssocPrompt => (440, 210), Unsaved => (420, 160), + AecDropWarning => (480, 230), + OverwriteWarning => (420, 180), SaveDialog => (560, 480), PointStyle => (360, 470), }; diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index fa0515c59..2639e0aae 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -466,6 +466,25 @@ impl OpenCADStudio { ), 720, 560) } super::super::ModalKind::AssocPrompt => sized(default_assoc_dialog_window(), 440, 210), + super::super::ModalKind::AecDropWarning => { + let src_label = self + .tabs + .get(self.active_tab) + .map(|t| crate::io::format_for_version(t.scene.document.version, false)) + .unwrap_or_else(|| "DWG".to_string()); + sized( + aec_drop_dialog_window( + self.aec_drop_count, + &self.save_dialog_format, + &src_label, + ), + 480, + 230, + ) + } + super::super::ModalKind::OverwriteWarning => { + sized(overwrite_dialog_window(&self.save_dialog_filename), 420, 180) + } super::super::ModalKind::Unsaved => { let tab_name = match &self.pending_close { Some(super::super::PendingClose::Tab(idx)) => self @@ -880,6 +899,123 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> { .into() } +/// Warning shown before a lossy Save-As: the drawing carries unsupported +/// (AEC / application) objects that survive only as verbatim source-version +/// bytes, so saving to a different version or to DXF would drop them. Offers to +/// save in the source version (keep them) or proceed (drop them). +fn aec_drop_dialog_window(count: usize, target: &str, src_version: &str) -> Element<'static, Message> { + const BG: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 }; + const BORDER_COL: Color = Color { r: 0.38, g: 0.38, b: 0.42, a: 1.0 }; + const TEXT_COL: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 }; + const BTN_SAVE: Color = Color { r: 0.20, g: 0.46, b: 0.80, a: 1.0 }; + const BTN_HOVER: Color = Color { r: 0.26, g: 0.55, b: 0.92, a: 1.0 }; + const BTN_DISC: Color = Color { r: 0.28, g: 0.28, b: 0.30, a: 1.0 }; + const BTN_DHOV: Color = Color { r: 0.36, g: 0.36, b: 0.40, a: 1.0 }; + + let body_text = format!( + "This drawing contains {count} AEC/Civil objects that \"{target}\" \ + cannot store, so they will not be saved.\n\n\ + To keep them, save in the source version ({src_version})." + ); + + let btn = |label: &'static str, msg: Message, base: Color, hov: Color| { + button(text(label).size(13).color(TEXT_COL)) + .on_press(msg) + .style(move |_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => hov, + _ => base, + })), + text_color: TEXT_COL, + border: Border { + color: BORDER_COL, + width: 1.0, + radius: 4.0.into(), + }, + shadow: iced::Shadow::default(), + snap: false, + }) + .padding([6, 14]) + }; + + container( + column![ + text(body_text).size(13).color(TEXT_COL), + iced::widget::Space::new().height(20), + row![ + btn("Save in source version", Message::AecDropSameVersion, BTN_SAVE, BTN_HOVER), + iced::widget::Space::new().width(8), + btn("Save anyway", Message::AecDropProceed, BTN_DISC, BTN_DHOV), + iced::widget::Space::new().width(8), + btn("Back", Message::AecDropBack, BTN_DISC, BTN_DHOV), + ], + ] + .spacing(0), + ) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .center(Fill) + .padding([24, 28]) + .into() +} + +/// Confirmation shown when the chosen Save-As filename already exists in the +/// target folder. "Replace" overwrites; "Cancel" returns to the Save dialog. +fn overwrite_dialog_window(filename: &str) -> Element<'static, Message> { + const BG: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 }; + const BORDER_COL: Color = Color { r: 0.38, g: 0.38, b: 0.42, a: 1.0 }; + const TEXT_COL: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 }; + const BTN_SAVE: Color = Color { r: 0.20, g: 0.46, b: 0.80, a: 1.0 }; + const BTN_HOVER: Color = Color { r: 0.26, g: 0.55, b: 0.92, a: 1.0 }; + const BTN_DISC: Color = Color { r: 0.28, g: 0.28, b: 0.30, a: 1.0 }; + const BTN_DHOV: Color = Color { r: 0.36, g: 0.36, b: 0.40, a: 1.0 }; + + let body_text = + format!("\"{filename}\" already exists in this folder.\n\nDo you want to replace it?"); + + let btn = |label: &'static str, msg: Message, base: Color, hov: Color| { + button(text(label).size(13).color(TEXT_COL)) + .on_press(msg) + .style(move |_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => hov, + _ => base, + })), + text_color: TEXT_COL, + border: Border { + color: BORDER_COL, + width: 1.0, + radius: 4.0.into(), + }, + shadow: iced::Shadow::default(), + snap: false, + }) + .padding([6, 18]) + }; + + container( + column![ + text(body_text).size(13).color(TEXT_COL), + iced::widget::Space::new().height(20), + row![ + btn("Replace", Message::OverwriteConfirm, BTN_SAVE, BTN_HOVER), + iced::widget::Space::new().width(8), + btn("Cancel", Message::OverwriteCancel, BTN_DISC, BTN_DHOV), + ], + ] + .spacing(0), + ) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .center(Fill) + .padding([24, 28]) + .into() +} + /// First-launch prompt offering to register Open CAD Studio as the default /// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now" /// just dismisses. Either answer flips the persisted `default_assoc_prompted` diff --git a/src/io/mod.rs b/src/io/mod.rs index 3aa646c8a..9f1772f0b 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -442,6 +442,37 @@ pub fn format_for_version(version: acadrust::DxfVersion, is_dxf: bool) -> String format!("{} {}", if is_dxf { "DXF" } else { "DWG" }, year) } +/// Count of unsupported "raw passthrough" objects/entities — AEC / application +/// objects with no native representation, kept only as verbatim source-version +/// bytes — that would be DROPPED when saving `doc` to `target_version` (or to +/// DXF). Returns 0 for a same-version DWG save, where they round-trip verbatim. +/// Used to warn the user before a lossy Save-As. +pub fn dropped_on_save_count( + doc: &acadrust::CadDocument, + target_version: acadrust::DxfVersion, + is_dxf: bool, +) -> usize { + if !is_dxf && target_version == doc.version { + return 0; + } + let mut n = doc + .objects + .values() + .filter(|o| { + matches!( + o, + acadrust::objects::ObjectType::Unknown { raw_dwg_data: Some(_), .. } + ) + }) + .count(); + for e in doc.entities() { + if matches!(e, acadrust::EntityType::Unknown(_)) { + n += 1; + } + } + n +} + // ── Plot Style Table ────────────────────────────────────────────────────── /// Show a file-open dialog and load the selected CTB or STB file.