fix(layers): protect locked entities

Keep locked-layer objects selectable for inspection while suppressing grips, property edits, command mutations, and stale editor writes.
This commit is contained in:
Hakan Seven 2026-08-12 12:46:41 +03:00
commit 0be9c41773
25 changed files with 524 additions and 118 deletions

View file

@ -239,6 +239,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
// Grip-menu value prompt — consume the typed number and
// route it through `apply_grip_menu_value`.
if let Some(pending) = self.grip_pending.take() {
let i = self.active_tab;
if self.reject_locked_edit(i, pending.handle) {
self.cancel_active_grip_edit();
return Task::none();
}
let raw = crate::app::expr_eval::eval_to_string(self.command_line.input.trim());
self.command_line.input.clear();
let Ok(v) = raw.parse::<f64>() else {
@ -249,7 +254,6 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.grip_pending = Some(pending);
return self.focus_cmd_input();
};
let i = self.active_tab;
let interactive_lengthen = self.tabs[i]
.active_grip
.as_ref()
@ -1141,6 +1145,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
let Some(popup) = self.grip_popup.take() else {
return Task::none();
};
if self.reject_locked_edit(i, popup.handle) {
return Task::none();
}
self.grip_hover = None;
let Some(item) = popup.items.get(idx).cloned() else {
return Task::none();
@ -1517,6 +1524,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
if self.has_property_selection(i) {
return Task::none();
}
// No selection — change the creation default. Persist
// into the tab's header (CLAYER) so it survives a tab
// switch and rides the next save. #21.
@ -1559,6 +1569,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
if self.has_property_selection(i) {
return Task::none();
}
// Persist the new default into the tab's header so it
// round-trips through tab switches and writes back on
// save (CECOLOR). #21.
@ -1585,6 +1598,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
if self.has_property_selection(i) {
return Task::none();
}
// Persist into the tab's header (CELTYPE). Resolve to a
// handle when the name matches a line_types entry so the
// handle-based lookup stays in sync. #21.
@ -2361,6 +2377,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
/// attributes. Entry points: double-clicking such a block, or ATTEDIT.
pub(crate) fn open_attribute_editor(&mut self, handle: acadrust::Handle) {
let i = self.active_tab;
if self.reject_locked_edit(i, handle) {
return;
}
let doc = &self.tabs[i].scene.document;
// Ok((block, rows)) to open; Err(msg) to report and stay closed. The
// borrow of `doc` ends with this match, before any `self` mutation.

View file

@ -2320,6 +2320,7 @@ impl OpenCADStudio {
if locked { "locked" } else { "unlocked" }
).as_ref());
self.sync_ribbon_layers();
self.refresh_properties();
}
}
Task::none()
@ -3203,6 +3204,9 @@ impl OpenCADStudio {
Message::AnnoObjectScaleToggle(name) => {
let i = self.active_tab;
if let Some(entity) = self.anno_object_scale_target {
if self.tabs[i].scene.is_layer_locked(entity) {
return Task::none();
}
if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) {
self.push_undo_snapshot(i, "OBJECTSCALE");
let doc = &mut self.tabs[i].scene.document;
@ -4249,6 +4253,9 @@ impl OpenCADStudio {
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
if self.has_property_selection(i) {
return Task::none();
}
// Persist into the tab's header (CELWEIGHT). #21.
self.tabs[i].scene.document.header.current_line_weight = lw.value();
self.tabs[i].dirty = true;
@ -4322,6 +4329,9 @@ impl OpenCADStudio {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if handles.is_empty() {
if self.has_property_selection(i) {
return Task::none();
}
self.tabs[i].scene.document.header.current_line_weight = lw.value();
self.tabs[i].dirty = true;
self.ribbon.active_lineweight = lw;

View file

@ -1079,6 +1079,14 @@ impl OpenCADStudio {
// ── Grip drag ─────────────────────────────────────────────
if let Some(grip) = self.tabs[i].active_grip.clone() {
if grip
.targets
.iter()
.any(|target| self.tabs[i].scene.is_layer_locked(target.handle))
{
self.cancel_active_grip_edit();
return Task::none();
}
let grip_started = Instant::now();
let (vw, vh) = vp_size;
let bounds = iced::Rectangle {
@ -3632,8 +3640,6 @@ impl OpenCADStudio {
bounds,
candidate_handles.as_ref(),
));
// Objects on a locked layer aren't selectable.
handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h));
// Box/lasso accumulates like individual picks
// (issue #83): a plain box adds to the current
// selection, Shift+box removes the boxed
@ -3746,7 +3752,6 @@ impl OpenCADStudio {
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.filter(|&h| self.tabs[i].scene.passes_selection_filter(h))
.filter(|&h| !self.tabs[i].scene.is_layer_locked(h))
.collect();
if cands.len() >= 2 {
// Overlap: open the list box at the cursor.
@ -3806,45 +3811,33 @@ impl OpenCADStudio {
// Selection filter: drop a pick whose type is excluded.
let hit = hit.filter(|&h| self.tabs[i].scene.passes_selection_filter(h));
if let Some(handle) = hit {
if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) {
// Locked layer: visible + snappable but
// not selectable. Report and do nothing
// else — in particular do NOT set
// `selection_just_completed`, or a
// gather command (MOVE's "select
// objects") would wrongly finish.
self.command_line.push_info(crate::tf!(
"Object is on locked layer \"{layer}\" — unlock the layer to select or edit it."
).as_ref());
} else {
// Individual picks accumulate (issue #47):
// each plain click adds to the selection,
// Shift+click removes the picked entity.
// PICKADD 0 (#226): a plain click
// REPLACES the selection instead and
// Shift+click toggles membership.
if self.shift_down || self.select_remove_mode {
// Remove was asked for by name, so it only
// ever takes away — the toggle below is
// Shift's PICKADD-0 behaviour, not its.
if !self.select_remove_mode
&& !selection_pick_add
&& !self.tabs[i].scene.selected.contains(&handle)
{
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].scene.expand_selection_for_groups(&[handle]);
} else {
self.tabs[i].scene.deselect_entity(handle);
}
} else {
self.tabs[i]
.scene
.select_entity(handle, !selection_pick_add);
// Individual picks accumulate (issue #47):
// each plain click adds to the selection,
// Shift+click removes the picked entity.
// PICKADD 0 (#226): a plain click
// REPLACES the selection instead and
// Shift+click toggles membership.
if self.shift_down || self.select_remove_mode {
// Remove was asked for by name, so it only
// ever takes away — the toggle below is
// Shift's PICKADD-0 behaviour, not its.
if !self.select_remove_mode
&& !selection_pick_add
&& !self.tabs[i].scene.selected.contains(&handle)
{
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].scene.expand_selection_for_groups(&[handle]);
} else {
self.tabs[i].scene.deselect_entity(handle);
}
self.refresh_properties();
selection_just_completed = true;
} else {
self.tabs[i]
.scene
.select_entity(handle, !selection_pick_add);
self.tabs[i].scene.expand_selection_for_groups(&[handle]);
}
self.refresh_properties();
selection_just_completed = true;
} else {
// Empty-space click only ARMS a box here; it
// no longer clears the selection, so a box can
@ -4011,8 +4004,6 @@ impl OpenCADStudio {
));
// Selection filter: keep only allowed types.
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
// Objects on a locked layer aren't selectable.
handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h));
// Accumulate (issue #83): a plain box adds to the
// current selection, Shift+box removes the boxed
// entities. An empty box leaves the selection alone