From 2712bb80e43d31f31082e725c46349ab2e3b5ba2 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 1 Jul 2026 17:23:32 +0300 Subject: [PATCH] fix(layers): deleting a non-empty layer warns and removes its objects (#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a layer only dropped the layer record, leaving its objects in the file still tagged with the (now-gone) layer name. Now: - Empty layer → deleted straight away. - Non-empty layer → a warning modal ("Layer X is not empty — N objects; deleting also removes them. Continue?") with Delete Objects / Cancel. Confirming erases every object on the layer, then removes the layer, as one undo step. The layer record is dropped before the erase so the locked-layer guard can't keep its objects. - Layer "0" and the current layer can't be deleted (reported on the command line) instead of silently leaving a broken state. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 8 ++++ src/app/update/command.rs | 89 +++++++++++++++++++++++++++++++-------- src/app/update/mod.rs | 4 ++ src/app/view/mod.rs | 1 + src/app/view/modal.rs | 61 +++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 17 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 1af85ecdc..7001a19e9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -348,6 +348,10 @@ pub(super) struct OpenCADStudio { aec_drop_count: usize, /// Set once the user confirms overwriting an existing file on Save-As. overwrite_acknowledged: bool, + /// Layer awaiting a "delete non-empty layer" confirmation: `(name, object + /// count)`. Set when the user deletes a layer that still has objects; the + /// warning modal reads it, and confirming erases those objects too. + layer_delete_pending: Option<(String, usize)>, /// 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, @@ -958,6 +962,7 @@ pub enum ModalKind { AssocPrompt, PointStyle, AttributeEditor, + LayerDeleteWarning, } /// Identifies a DimStyle field that can be edited in the dialog. @@ -1180,6 +1185,8 @@ pub enum Message { LayerToggleVpFreeze(usize, usize), LayerNew, LayerDelete, + /// Confirm deleting a non-empty layer (erases its objects too). + LayerDeleteConfirm, LayerSetCurrent, LayerSelect(usize), LayerRenameStart(usize), @@ -1955,6 +1962,7 @@ impl OpenCADStudio { aec_drop_acknowledged: false, aec_drop_count: 0, overwrite_acknowledged: false, + layer_delete_pending: None, modal_offset: iced::Vector::ZERO, modal_drag_last: None, modal_dragging: false, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 8b03d1882..1b1a4c745 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -623,30 +623,85 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { pub(super) fn on_layer_delete(&mut self) -> Task { let i = self.active_tab; - if let Some(idx) = self.tabs[i].layers.selected { - let name = self.tabs[i] - .layers - .layers - .get(idx) - .map(|l| l.name.clone()) - .unwrap_or_default(); - if name == "0" { - return Task::none(); - } + let Some(idx) = self.tabs[i].layers.selected else { + return Task::none(); + }; + let name = self.tabs[i] + .layers + .layers + .get(idx) + .map(|l| l.name.clone()) + .unwrap_or_default(); + if name.is_empty() { + return Task::none(); + } + if name == "0" { + self.command_line + .push_error("Layer \"0\" cannot be deleted."); + return Task::none(); + } + if name == self.tabs[i].scene.document.header.current_layer_name { + self.command_line + .push_error("Cannot delete the current layer. Set another layer current first."); + return Task::none(); + } + // Count objects on the layer. Empty → delete straight away; + // non-empty → warn first (deleting also removes those objects). + let count = self.tabs[i] + .scene + .document + .entities() + .filter(|e| e.common().layer == name) + .count(); + if count == 0 { self.push_undo_snapshot(i, "LAYER DELETE"); self.tabs[i].scene.document.layers.remove(&name); self.tabs[i].dirty = true; - let doc_layers = self.tabs[i].scene.document.layers.clone(); - let vp_info = self.tabs[i].scene.viewport_list(); - self.tabs[i] - .layers - .sync_with_viewports(&doc_layers, vp_info); - self.tabs[i].layers.selected = None; - self.sync_ribbon_layers(); + self.sync_layer_panel(i); + } else { + self.layer_delete_pending = Some((name, count)); + self.active_modal = Some(crate::app::ModalKind::LayerDeleteWarning); + self.modal_offset = iced::Vector::ZERO; } Task::none() } + /// User confirmed deleting a non-empty layer: erase every object on it, + /// then remove the layer record. + pub(super) fn on_layer_delete_confirm(&mut self) -> Task { + let i = self.active_tab; + self.active_modal = None; + self.modal_offset = iced::Vector::ZERO; + let Some((name, _)) = self.layer_delete_pending.take() else { + return Task::none(); + }; + self.push_undo_snapshot(i, "LAYER DELETE"); + let handles: Vec = self.tabs[i] + .scene + .document + .entities() + .filter(|e| e.common().layer == name) + .map(|e| e.common().handle) + .collect(); + // Remove the layer record first so the lock guard in `erase_entities` + // (which looks the layer up by name) can't block the objects. + self.tabs[i].scene.document.layers.remove(&name); + self.tabs[i].scene.erase_entities(&handles); + self.tabs[i].dirty = true; + self.sync_layer_panel(i); + self.refresh_properties(); + Task::none() + } + + /// Rebuild the Layer-manager panel + ribbon after a layer-table change. + fn sync_layer_panel(&mut self, i: usize) { + let doc_layers = self.tabs[i].scene.document.layers.clone(); + let vp_info = self.tabs[i].scene.viewport_list(); + self.tabs[i].layers.sync_with_viewports(&doc_layers, vp_info); + self.tabs[i].layers.selected = None; + self.sync_ribbon_layers(); + } + pub(super) fn on_layer_set_current(&mut self) -> Task { let i = self.active_tab; if let Some(idx) = self.tabs[i].layers.selected { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 32b60fb2c..b25ecc8e3 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -104,6 +104,8 @@ impl OpenCADStudio { // Dismissing these via ✕ is the cancel/decline path. Some(Unsaved) => self.pending_close = None, Some(AssocPrompt) => self.mark_assoc_prompted(), + // Cancel: leave the layer (and its objects) untouched. + Some(LayerDeleteWarning) => self.layer_delete_pending = None, // Cancel: drop the working copy without touching the block. Some(AttributeEditor) => { self.attr_editor_handle = None; @@ -914,6 +916,8 @@ impl OpenCADStudio { Message::LayerDelete => self.on_layer_delete(), + Message::LayerDeleteConfirm => self.on_layer_delete_confirm(), + Message::LayerSetCurrent => self.on_layer_set_current(), Message::LayerSelect(idx) => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 20466ed1f..27c16e21b 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1370,6 +1370,7 @@ impl OpenCADStudio { SaveDialog => (560, 480), PointStyle => (360, 470), AttributeEditor => (640, 500), + LayerDeleteWarning => (440, 200), }; Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H)) } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index f5f4f91ac..345198c58 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -485,6 +485,13 @@ impl OpenCADStudio { super::super::ModalKind::OverwriteWarning => { sized(overwrite_dialog_window(&self.save_dialog_filename), 420, 180) } + super::super::ModalKind::LayerDeleteWarning => { + let (name, count) = self + .layer_delete_pending + .clone() + .unwrap_or_else(|| (String::new(), 0)); + sized(layer_delete_warning_window(&name, count), 440, 200) + } super::super::ModalKind::Unsaved => { let tab_name = match &self.pending_close { Some(super::super::PendingClose::Tab(idx)) => self @@ -1046,6 +1053,60 @@ fn overwrite_dialog_window(filename: &str) -> Element<'static, Message> { .into() } +/// Confirm deleting a layer that still has objects on it. "Delete Objects" +/// erases them and removes the layer; "Cancel" leaves everything. +fn layer_delete_warning_window(name: &str, count: usize) -> 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_DEL: Color = Color { r: 0.72, g: 0.26, b: 0.24, a: 1.0 }; + const BTN_DEL_HOV: Color = Color { r: 0.84, g: 0.32, b: 0.30, a: 1.0 }; + const BTN_CANCEL: Color = Color { r: 0.28, g: 0.28, b: 0.30, a: 1.0 }; + const BTN_CANCEL_HOV: Color = Color { r: 0.36, g: 0.36, b: 0.40, a: 1.0 }; + + let obj = if count == 1 { "object" } else { "objects" }; + let body_text = format!( + "Layer \"{name}\" is not empty — it still has {count} {obj}.\n\nDeleting the layer will also remove {} from the drawing. Continue?", + if count == 1 { "that object" } else { "those objects" } + ); + + 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("Delete Objects", Message::LayerDeleteConfirm, BTN_DEL, BTN_DEL_HOV), + iced::widget::Space::new().width(8), + btn("Cancel", Message::CloseModal, BTN_CANCEL, BTN_CANCEL_HOV), + ], + ] + .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`