fix(layers): deleting a non-empty layer warns and removes its objects (#237)
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 <noreply@anthropic.com>
This commit is contained in:
parent
e3d1780a69
commit
2712bb80e4
5 changed files with 146 additions and 17 deletions
|
|
@ -348,6 +348,10 @@ pub(super) struct OpenCADStudio {
|
||||||
aec_drop_count: usize,
|
aec_drop_count: usize,
|
||||||
/// Set once the user confirms overwriting an existing file on Save-As.
|
/// Set once the user confirms overwriting an existing file on Save-As.
|
||||||
overwrite_acknowledged: bool,
|
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).
|
/// Pixel offset of the active modal from screen-centre (drag-to-move).
|
||||||
/// Reset to zero whenever a modal closes so each dialog opens centred.
|
/// Reset to zero whenever a modal closes so each dialog opens centred.
|
||||||
modal_offset: iced::Vector,
|
modal_offset: iced::Vector,
|
||||||
|
|
@ -958,6 +962,7 @@ pub enum ModalKind {
|
||||||
AssocPrompt,
|
AssocPrompt,
|
||||||
PointStyle,
|
PointStyle,
|
||||||
AttributeEditor,
|
AttributeEditor,
|
||||||
|
LayerDeleteWarning,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identifies a DimStyle field that can be edited in the dialog.
|
/// Identifies a DimStyle field that can be edited in the dialog.
|
||||||
|
|
@ -1180,6 +1185,8 @@ pub enum Message {
|
||||||
LayerToggleVpFreeze(usize, usize),
|
LayerToggleVpFreeze(usize, usize),
|
||||||
LayerNew,
|
LayerNew,
|
||||||
LayerDelete,
|
LayerDelete,
|
||||||
|
/// Confirm deleting a non-empty layer (erases its objects too).
|
||||||
|
LayerDeleteConfirm,
|
||||||
LayerSetCurrent,
|
LayerSetCurrent,
|
||||||
LayerSelect(usize),
|
LayerSelect(usize),
|
||||||
LayerRenameStart(usize),
|
LayerRenameStart(usize),
|
||||||
|
|
@ -1955,6 +1962,7 @@ impl OpenCADStudio {
|
||||||
aec_drop_acknowledged: false,
|
aec_drop_acknowledged: false,
|
||||||
aec_drop_count: 0,
|
aec_drop_count: 0,
|
||||||
overwrite_acknowledged: false,
|
overwrite_acknowledged: false,
|
||||||
|
layer_delete_pending: None,
|
||||||
modal_offset: iced::Vector::ZERO,
|
modal_offset: iced::Vector::ZERO,
|
||||||
modal_drag_last: None,
|
modal_drag_last: None,
|
||||||
modal_dragging: false,
|
modal_dragging: false,
|
||||||
|
|
|
||||||
|
|
@ -623,30 +623,85 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||||
|
|
||||||
pub(super) fn on_layer_delete(&mut self) -> Task<Message> {
|
pub(super) fn on_layer_delete(&mut self) -> Task<Message> {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
if let Some(idx) = self.tabs[i].layers.selected {
|
let Some(idx) = self.tabs[i].layers.selected else {
|
||||||
let name = self.tabs[i]
|
return Task::none();
|
||||||
.layers
|
};
|
||||||
.layers
|
let name = self.tabs[i]
|
||||||
.get(idx)
|
.layers
|
||||||
.map(|l| l.name.clone())
|
.layers
|
||||||
.unwrap_or_default();
|
.get(idx)
|
||||||
if name == "0" {
|
.map(|l| l.name.clone())
|
||||||
return Task::none();
|
.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.push_undo_snapshot(i, "LAYER DELETE");
|
||||||
self.tabs[i].scene.document.layers.remove(&name);
|
self.tabs[i].scene.document.layers.remove(&name);
|
||||||
self.tabs[i].dirty = true;
|
self.tabs[i].dirty = true;
|
||||||
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
self.sync_layer_panel(i);
|
||||||
let vp_info = self.tabs[i].scene.viewport_list();
|
} else {
|
||||||
self.tabs[i]
|
self.layer_delete_pending = Some((name, count));
|
||||||
.layers
|
self.active_modal = Some(crate::app::ModalKind::LayerDeleteWarning);
|
||||||
.sync_with_viewports(&doc_layers, vp_info);
|
self.modal_offset = iced::Vector::ZERO;
|
||||||
self.tabs[i].layers.selected = None;
|
|
||||||
self.sync_ribbon_layers();
|
|
||||||
}
|
}
|
||||||
Task::none()
|
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<Message> {
|
||||||
|
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<acadrust::Handle> = 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<Message> {
|
pub(super) fn on_layer_set_current(&mut self) -> Task<Message> {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
if let Some(idx) = self.tabs[i].layers.selected {
|
if let Some(idx) = self.tabs[i].layers.selected {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@ impl OpenCADStudio {
|
||||||
// Dismissing these via ✕ is the cancel/decline path.
|
// Dismissing these via ✕ is the cancel/decline path.
|
||||||
Some(Unsaved) => self.pending_close = None,
|
Some(Unsaved) => self.pending_close = None,
|
||||||
Some(AssocPrompt) => self.mark_assoc_prompted(),
|
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.
|
// Cancel: drop the working copy without touching the block.
|
||||||
Some(AttributeEditor) => {
|
Some(AttributeEditor) => {
|
||||||
self.attr_editor_handle = None;
|
self.attr_editor_handle = None;
|
||||||
|
|
@ -914,6 +916,8 @@ impl OpenCADStudio {
|
||||||
|
|
||||||
Message::LayerDelete => self.on_layer_delete(),
|
Message::LayerDelete => self.on_layer_delete(),
|
||||||
|
|
||||||
|
Message::LayerDeleteConfirm => self.on_layer_delete_confirm(),
|
||||||
|
|
||||||
Message::LayerSetCurrent => self.on_layer_set_current(),
|
Message::LayerSetCurrent => self.on_layer_set_current(),
|
||||||
|
|
||||||
Message::LayerSelect(idx) => {
|
Message::LayerSelect(idx) => {
|
||||||
|
|
|
||||||
|
|
@ -1370,6 +1370,7 @@ impl OpenCADStudio {
|
||||||
SaveDialog => (560, 480),
|
SaveDialog => (560, 480),
|
||||||
PointStyle => (360, 470),
|
PointStyle => (360, 470),
|
||||||
AttributeEditor => (640, 500),
|
AttributeEditor => (640, 500),
|
||||||
|
LayerDeleteWarning => (440, 200),
|
||||||
};
|
};
|
||||||
Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H))
|
Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -485,6 +485,13 @@ impl OpenCADStudio {
|
||||||
super::super::ModalKind::OverwriteWarning => {
|
super::super::ModalKind::OverwriteWarning => {
|
||||||
sized(overwrite_dialog_window(&self.save_dialog_filename), 420, 180)
|
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 => {
|
super::super::ModalKind::Unsaved => {
|
||||||
let tab_name = match &self.pending_close {
|
let tab_name = match &self.pending_close {
|
||||||
Some(super::super::PendingClose::Tab(idx)) => self
|
Some(super::super::PendingClose::Tab(idx)) => self
|
||||||
|
|
@ -1046,6 +1053,60 @@ fn overwrite_dialog_window(filename: &str) -> Element<'static, Message> {
|
||||||
.into()
|
.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
|
/// First-launch prompt offering to register Open CAD Studio as the default
|
||||||
/// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now"
|
/// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now"
|
||||||
/// just dismisses. Either answer flips the persisted `default_assoc_prompted`
|
/// just dismisses. Either answer flips the persisted `default_assoc_prompted`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue