feat(layers): multi-select layers in the Layer Manager (#236)

The Layer Manager only allowed one layer at a time. Now:

- Ctrl/Cmd-click toggles a layer in the selection; Shift-click selects
  the range from the anchor; a plain click selects just one.
- Colour, linetype and lineweight changes apply to every selected layer.
- Delete removes all selected layers at once (reusing the #237 non-empty
  warning, now pluralised); layer "0" and the current layer are skipped.

Selection is stored as row indices but re-resolved by name across sorts
and layer-table rebuilds, so it never points at the wrong rows. Modifier
state is tracked via a new SetModifiers message (shift + ctrl/cmd).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-01 17:44:19 +03:00
1 parent 2712bb80e4
commit ada9952f06
6 files changed
+180 -75

No files matched your search

+10 -5
View File
@@ -348,10 +348,11 @@ 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)>,
/// Layers awaiting a "delete non-empty layer(s)" confirmation: `(names,
/// total object count)`. Set when the user deletes one or more layers that
/// still have objects; the warning modal reads it, and confirming erases
/// those objects too.
layer_delete_pending: Option<(Vec<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,
@@ -433,6 +434,9 @@ pub(super) struct OpenCADStudio {
/// removes the picked entity from the selection). Tracked from keyboard
/// modifier-change events since mouse click messages carry no modifiers.
shift_down: bool,
/// True while Ctrl/Cmd is held — drives Ctrl-click multi-select in the
/// Layer Manager. Tracked from modifier-change events.
ctrl_down: bool,
/// Open in-place MText editor (toolbar + text area + live preview), if any.
mtext_editor: Option<mtext_editor::MTextEditorState>,
/// Open in-place single-line TEXT editor (plain text-entry box), if any.
@@ -1546,7 +1550,7 @@ pub enum Message {
InvertSelection,
/// Keyboard modifier state changed — tracks whether Shift is held so the
/// pick path can do subtractive (Shift+click) selection.
SetShiftDown(bool),
SetModifiers { shift: bool, ctrl: bool },
// ── In-place MText editor ───────────────────────────────────────────
/// Text-area edit action from the multi-line editor widget.
MTextEdit(iced::widget::text_editor::Action),
@@ -1991,6 +1995,7 @@ impl OpenCADStudio {
clipboard_centroid: glam::DVec3::ZERO,
clipboard_deps: ClipboardDeps::default(),
shift_down: false,
ctrl_down: false,
mtext_editor: None,
text_inline: None,
layout_context_menu: None,
+43 -27
View File
@@ -614,6 +614,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
.position(|l| l.name == new_name);
if let Some(idx) = new_idx {
self.tabs[i].layers.selected = Some(idx);
self.tabs[i].layers.selected_multi = vec![idx];
self.tabs[i].layers.editing = Some(idx);
self.tabs[i].layers.edit_buf = new_name.clone();
}
@@ -623,56 +624,53 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
pub(super) fn on_layer_delete(&mut self) -> Task<Message> {
let i = self.active_tab;
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() {
// Every selected layer (multi-select), or the anchor if none.
let mut names = self.selected_layer_names(i);
if names.is_empty() {
return Task::none();
}
if name == "0" {
// Layer "0" and the current layer can't be deleted — drop them
// from the batch and note it.
let current = self.tabs[i].scene.document.header.current_layer_name.clone();
let before = names.len();
names.retain(|n| n != "0" && *n != current);
if names.len() < before {
self.command_line
.push_error("Layer \"0\" cannot be deleted.");
.push_info("Layer \"0\" and the current layer can't be deleted — skipped.");
}
if names.is_empty() {
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;
// Total objects across the layers. 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)
.filter(|e| names.contains(&e.common().layer))
.count();
if count == 0 {
self.push_undo_snapshot(i, "LAYER DELETE");
self.tabs[i].scene.document.layers.remove(&name);
for name in &names {
self.tabs[i].scene.document.layers.remove(name);
}
self.tabs[i].dirty = true;
self.sync_layer_panel(i);
} else {
self.layer_delete_pending = Some((name, count));
self.layer_delete_pending = Some((names, 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.
/// User confirmed deleting non-empty layer(s): erase every object on them,
/// then remove the layer records.
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 {
let Some((names, _)) = self.layer_delete_pending.take() else {
return Task::none();
};
self.push_undo_snapshot(i, "LAYER DELETE");
@@ -680,12 +678,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
.scene
.document
.entities()
.filter(|e| e.common().layer == name)
.filter(|e| names.contains(&e.common().layer))
.map(|e| e.common().handle)
.collect();
// Remove the layer record first so the lock guard in `erase_entities`
// Remove the layer records 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);
for name in &names {
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);
@@ -693,12 +693,28 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
Task::none()
}
/// Layer names for the current Layer-manager selection — every row in the
/// multi-selection, or the anchor row when the multi-set is empty. Bulk
/// property edits and deletion act on these.
pub(super) fn selected_layer_names(&self, i: usize) -> Vec<String> {
let panel = &self.tabs[i].layers;
let idxs: Vec<usize> = if panel.selected_multi.is_empty() {
panel.selected.into_iter().collect()
} else {
panel.selected_multi.clone()
};
idxs.iter()
.filter_map(|&x| panel.layers.get(x).map(|l| l.name.clone()))
.collect()
}
/// 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.tabs[i].layers.selected_multi.clear();
self.sync_ribbon_layers();
}
+66 -33
View File
@@ -925,13 +925,32 @@ impl OpenCADStudio {
if self.tabs[i].layers.editing.is_some() {
return Task::done(Message::LayerRenameCommit);
}
self.tabs[i].layers.selected = Some(idx);
let (shift, ctrl) = (self.shift_down, self.ctrl_down);
let panel = &mut self.tabs[i].layers;
if ctrl {
// Ctrl/Cmd-click toggles this row in the selection.
if let Some(pos) = panel.selected_multi.iter().position(|&x| x == idx) {
panel.selected_multi.remove(pos);
} else {
panel.selected_multi.push(idx);
}
} else if shift {
// Shift-click selects the range from the anchor to here.
let anchor = panel.selected.unwrap_or(idx);
let (lo, hi) = (anchor.min(idx), anchor.max(idx));
panel.selected_multi = (lo..=hi).collect();
} else {
// Plain click selects just this row.
panel.selected_multi = vec![idx];
}
panel.selected = Some(idx);
Task::none()
}
Message::LayerRenameStart(idx) => {
let i = self.active_tab;
self.tabs[i].layers.selected = Some(idx);
self.tabs[i].layers.selected_multi = vec![idx];
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
self.tabs[i].layers.edit_buf = layer.name.clone();
}
@@ -957,6 +976,12 @@ impl OpenCADStudio {
panel.color_picker_row = Some(idx);
panel.color_full_palette = false;
panel.selected = Some(idx);
// Opening the swatch on a row outside the current
// multi-selection narrows to just that row; on a selected
// row it keeps the multi-selection so the pick applies to all.
if !panel.selected_multi.contains(&idx) {
panel.selected_multi = vec![idx];
}
}
Task::none()
}
@@ -969,23 +994,26 @@ impl OpenCADStudio {
Message::LayerColorSet(aci) => {
let i = self.active_tab;
if let Some(idx) = self.tabs[i].layers.selected {
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
let name = layer.name.clone();
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
// Apply to every selected layer (multi-select), not just one.
let names = self.selected_layer_names(i);
if !names.is_empty() {
use crate::ui::window::layers::iced_color_from_acad;
let new_color = iced_color_from_acad(&AcadColor::Index(aci));
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.color = AcadColor::Index(aci);
}
use crate::ui::window::layers::iced_color_from_acad;
let new_color = iced_color_from_acad(&AcadColor::Index(aci));
if let Some(pl) = self.tabs[i].layers.layers.get_mut(idx) {
}
for pl in self.tabs[i].layers.layers.iter_mut() {
if names.contains(&pl.name) {
pl.color = new_color;
}
self.tabs[i].dirty = true;
// ByLayer color is baked into the cached wires at
// tessellation time, so bump the geometry epoch to
// invalidate the wire cache and repaint with the new color.
self.tabs[i].scene.bump_geometry();
}
self.tabs[i].dirty = true;
// ByLayer color is baked into the cached wires at
// tessellation time, so bump the geometry epoch to
// invalidate the wire cache and repaint with the new color.
self.tabs[i].scene.bump_geometry();
self.tabs[i].layers.color_picker_row = None;
self.tabs[i].layers.color_full_palette = false;
self.sync_ribbon_layers();
@@ -995,38 +1023,42 @@ impl OpenCADStudio {
Message::LayerLinetypeSet(lt) => {
let i = self.active_tab;
if let Some(idx) = self.tabs[i].layers.selected {
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
let name = layer.name.clone();
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
let names = self.selected_layer_names(i);
if !names.is_empty() {
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.line_type = lt.clone();
}
if let Some(pl) = self.tabs[i].layers.layers.get_mut(idx) {
pl.linetype = lt;
}
self.tabs[i].dirty = true;
// Linetype is baked into the cached wires; repaint.
self.tabs[i].scene.bump_geometry();
}
for pl in self.tabs[i].layers.layers.iter_mut() {
if names.contains(&pl.name) {
pl.linetype = lt.clone();
}
}
self.tabs[i].dirty = true;
// Linetype is baked into the cached wires; repaint.
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
Message::LayerLineweightSet(lw) => {
let i = self.active_tab;
if let Some(idx) = self.tabs[i].layers.selected {
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
let name = layer.name.clone();
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
let names = self.selected_layer_names(i);
if !names.is_empty() {
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.line_weight = lw;
}
if let Some(pl) = self.tabs[i].layers.layers.get_mut(idx) {
}
for pl in self.tabs[i].layers.layers.iter_mut() {
if names.contains(&pl.name) {
pl.lineweight = lw;
}
self.tabs[i].dirty = true;
// Lineweight is baked into the cached wires; repaint.
self.tabs[i].scene.bump_geometry();
}
self.tabs[i].dirty = true;
// Lineweight is baked into the cached wires; repaint.
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
@@ -1585,8 +1617,9 @@ impl OpenCADStudio {
Task::none()
}
Message::SetShiftDown(down) => {
self.shift_down = down;
Message::SetModifiers { shift, ctrl } => {
self.shift_down = shift;
self.ctrl_down = ctrl;
Task::none()
}
+6 -1
View File
@@ -1464,7 +1464,12 @@ impl OpenCADStudio {
Some(Message::WindowResized(sz.width as f32, sz.height as f32))
}
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(m)) => {
Some(Message::SetShiftDown(m.shift()))
// `command()` is Cmd on macOS, Ctrl elsewhere — the
// platform multi-select modifier for the layer list.
Some(Message::SetModifiers {
shift: m.shift(),
ctrl: m.command() || m.control(),
})
}
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
+13 -7
View File
@@ -486,11 +486,11 @@ impl OpenCADStudio {
sized(overwrite_dialog_window(&self.save_dialog_filename), 420, 180)
}
super::super::ModalKind::LayerDeleteWarning => {
let (name, count) = self
let (names, count) = self
.layer_delete_pending
.clone()
.unwrap_or_else(|| (String::new(), 0));
sized(layer_delete_warning_window(&name, count), 440, 200)
.unwrap_or_else(|| (Vec::new(), 0));
sized(layer_delete_warning_window(&names, count), 440, 200)
}
super::super::ModalKind::Unsaved => {
let tab_name = match &self.pending_close {
@@ -1053,9 +1053,9 @@ 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> {
/// Confirm deleting layer(s) that still have objects on them. "Delete Objects"
/// erases them and removes the layers; "Cancel" leaves everything.
fn layer_delete_warning_window(names: &[String], 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 };
@@ -1065,8 +1065,14 @@ fn layer_delete_warning_window(name: &str, count: usize) -> Element<'static, Mes
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 subject = if names.len() == 1 {
format!("Layer \"{}\"", names[0])
} else {
format!("{} selected layers", names.len())
};
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?",
"{subject} still {} {count} {obj}.\n\nDeleting will also remove {} from the drawing. Continue?",
if names.len() == 1 { "has" } else { "hold" },
if count == 1 { "that object" } else { "those objects" }
);
+42 -2
View File
@@ -83,7 +83,12 @@ pub struct LayerPanel {
pub layers: Vec<Layer>,
#[allow(dead_code)]
pub visible: bool,
/// Anchor row: the last row clicked. Drives the editable combos and the
/// Shift-range origin. Its layer is always part of `selected_multi`.
pub selected: Option<usize>,
/// All selected rows (Ctrl/Shift extend it). Bulk property changes and
/// deletion act on every row here. Empty ⇔ nothing selected.
pub selected_multi: Vec<usize>,
pub editing: Option<usize>,
pub edit_buf: String,
pub current_layer: String,
@@ -106,6 +111,7 @@ impl Default for LayerPanel {
visible: false,
layers: vec![Layer::new("0", Color::WHITE)],
selected: None,
selected_multi: Vec::new(),
editing: None,
edit_buf: String::new(),
current_layer: "0".to_string(),
@@ -135,6 +141,18 @@ impl LayerPanel {
doc_layers: &Table<DocLayer>,
vp_info: Vec<(Handle, String, Vec<Handle>)>,
) {
// The rebuild below re-indexes rows, so capture the selection by name
// first and re-resolve it after (indices alone would go stale).
let anchor_name = self
.selected
.and_then(|i| self.layers.get(i))
.map(|l| l.name.clone());
let multi_names: Vec<String> = self
.selected_multi
.iter()
.filter_map(|&i| self.layers.get(i).map(|l| l.name.clone()))
.collect();
self.vp_cols = vp_info
.iter()
.map(|(h, label, _)| VpCol {
@@ -169,6 +187,14 @@ impl LayerPanel {
})
.collect();
// Re-resolve the selection against the rebuilt rows by name.
self.selected = anchor_name
.and_then(|n| self.layers.iter().position(|l| l.name == n));
self.selected_multi = multi_names
.iter()
.filter_map(|n| self.layers.iter().position(|l| l.name == *n))
.collect();
self.apply_sort();
}
@@ -194,6 +220,13 @@ impl LayerPanel {
.selected
.and_then(|i| self.layers.get(i))
.map(|l| l.name.clone());
// Sort reorders rows, so the multi-selection (stored as indices) must
// be re-resolved by name afterward or it would point at the wrong rows.
let multi_names: Vec<String> = self
.selected_multi
.iter()
.filter_map(|&i| self.layers.get(i).map(|l| l.name.clone()))
.collect();
use std::cmp::Ordering;
self.layers.sort_by(|a, b| {
@@ -225,6 +258,10 @@ impl LayerPanel {
if let Some(n) = sel_name {
self.selected = self.layers.iter().position(|l| l.name == n);
}
self.selected_multi = multi_names
.iter()
.filter_map(|n| self.layers.iter().position(|l| l.name == *n))
.collect();
}
pub fn sync_linetypes(&mut self, items: Vec<LinetypeItem>) {
@@ -318,12 +355,15 @@ impl LayerPanel {
// ── Layer rows ────────────────────────────────────────────────────
let mut rows_col = column![].spacing(0);
for (i, layer) in self.layers.iter().enumerate() {
let is_sel = self.selected == Some(i);
// Highlight every selected row; show the editable combos only on the
// anchor (a single shared combo state can't drive several rows).
let is_anchor = self.selected == Some(i);
let is_sel = is_anchor || self.selected_multi.contains(&i);
let is_current = layer.name == self.current_layer;
let is_editing = self.editing == Some(i);
let color_open = self.color_picker_row == Some(i);
let (ltc, lwc) = if is_sel {
let (ltc, lwc) = if is_anchor {
(Some(&self.linetype_combo), Some(&self.lw_combo))
} else {
(None, None)