forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
ab9285f114
commit
b77187e672
7 files changed
+267
-1
No files matched your search
@@ -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<ModalKind>,
|
||||
/// 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<std::path::PathBuf>),
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Message> {
|
||||
self.aec_drop_acknowledged = false;
|
||||
self.overwrite_acknowledged = false;
|
||||
if self.active_modal == Some(crate::app::ModalKind::SaveDialog) {
|
||||
self.active_modal = None;
|
||||
}
|
||||
|
||||
+57
-1
@@ -552,6 +552,23 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
|
||||
pub(super) fn on_save_dialog_confirm(&mut self) -> Task<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
let i = self.active_tab;
|
||||
let layout_name = self.tabs[i].scene.current_layout.clone();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user