From ada9952f06b8946f573a8ec06c93eaee60b22054 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 1 Jul 2026 17:44:19 +0300 Subject: [PATCH] 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 --- src/app/mod.rs | 15 ++++-- src/app/update/command.rs | 70 ++++++++++++++++----------- src/app/update/mod.rs | 99 ++++++++++++++++++++++++++------------- src/app/view/mod.rs | 7 ++- src/app/view/modal.rs | 20 +++++--- src/ui/window/layers.rs | 44 ++++++++++++++++- 6 files changed, 180 insertions(+), 75 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 7001a19e9..299761fab 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, 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, /// 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, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 1b1a4c745..9004e67a2 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -614,6 +614,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .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 { pub(super) fn on_layer_delete(&mut self) -> Task { 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 { 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 { .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 { 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 { + let panel = &self.tabs[i].layers; + let idxs: Vec = 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(); } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b25ecc8e3..898ef455f 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 27c16e21b..02556e653 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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, diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 345198c58..f20d83785 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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" } ); diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index 26d6c63e2..3044063dd 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -83,7 +83,12 @@ pub struct LayerPanel { pub layers: Vec, #[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, + /// All selected rows (Ctrl/Shift extend it). Bulk property changes and + /// deletion act on every row here. Empty ⇔ nothing selected. + pub selected_multi: Vec, pub editing: Option, 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, vp_info: Vec<(Handle, String, Vec)>, ) { + // 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 = 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 = 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) { @@ -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)