Delete for everything: selection.delete removes sketch entities, features and sketches (with their dependents), construction planes, bodies (Remove feature), occurrences and components, and canvases in one undo step, with a dry run; Delete/Backspace and every menu's Delete go through it and ask first, listing what goes with it and what would fail; faces say Delete Face is not available yet; ui.confirm control method

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 06:44:11 +00:00
1 parent 7f9a8f2afd
commit a7f301bde6
10 files changed
+476 -33

No files matched your search

+2
View File
@@ -32,6 +32,8 @@ pub struct TreeState {
pub redefine: Option<u64>,
/// Components picked in the tree (they have no viewport selection).
pub picked_components: Vec<u64>,
/// Canvases picked in the tree.
pub picked_canvases: Vec<u64>,
/// The last row clicked (for Shift ranges).
anchor: Option<String>,
/// Row keys in drawing order this frame (for Shift ranges).
+11 -4
View File
@@ -126,6 +126,8 @@ pub struct MenuState {
pending: Option<(Pos2, Target)>,
/// An inline rename in progress.
pub rename: Option<Rename>,
/// A delete waiting for its confirmation (it takes or breaks other features).
pub confirm: Option<crate::delete::Pending>,
/// The Properties window: title and measurements.
pub props: Option<(String, Value)>,
}
@@ -284,6 +286,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": face })));
v.push(act("ui.findTimeline", "Find in Timeline", "").with(json!({ "body": face })));
v.push(act("ui.hide", "Hide Body", "eye").with(json!({ "bodies": [face] })));
v.push(act("ui.delete", "Delete", "delete").key("Del"));
} else if has(|s| matches!(s, Sel::Edge { .. })) {
v.push(cmd(app, "FusionFilletEdgesCommand", "Fillet"));
v.push(cmd(app, "FusionChamferCommand", "Chamfer"));
@@ -338,7 +341,10 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec<Item> {
.with(json!({ "bodies": names })),
act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })),
act("ui.rename", "Rename", "").with(json!({ "body": bodies.first() })).on(one),
cmd(app, "SoftDeleteCommand", "Delete").key("Del").with(json!({ "bodies": names })).on(!locked),
act("ui.delete", "Delete", "delete")
.key("Del")
.with(json!({ "items": bodies.iter().map(|b| json!({"type": "body", "name": b})).collect::<Vec<_>>() }))
.on(!locked),
act("ui.lock", if locked { "Unlock" } else { "Lock" }, "").with(json!({ "bodies": names })),
Item::sep(),
act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })),
@@ -368,7 +374,7 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec<Item> {
act("ui.sketchDims", if dims { "Hide Dimensions" } else { "Show Dimensions" }, "dimension").with(json!({ "sketch": id })),
act("ui.sketchProfile", if profiles { "Hide Profile" } else { "Show Profile" }, "").with(json!({ "sketch": id })),
act("ui.rename", "Rename", "").with(json!({ "feature": id })),
cmd(app, "FusionDeleteCommand", "Delete").key("Del").with(json!({ "features": [id.to_string()] })),
act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })),
Item::sep(),
act("ui.findTimeline", "Find in Timeline", "").with(json!({ "feature": id })),
]
@@ -397,7 +403,7 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
if id != 0 {
let grounded = occ.is_some_and(|o| o.grounded);
let oid = occ.map(|o| o.id).unwrap_or(0);
v.push(cmd(app, "component.delete", "Delete").key("Del").with(json!({ "component": id })));
v.push(act("ui.delete", "Delete", "delete").key("Del").with(json!({ "occurrences": [occ.map(|o| o.id)] })).on(occ.is_some()));
v.push(Item::sep());
v.push(act("ui.moveOccurrence", "Move/Copy", "move").with(json!({ "occurrence": oid })).on(!grounded && occ.is_some()));
v.push(cmd(app, "occurrence.ground", if grounded { "Unground" } else { "Ground" }).with(json!({ "occurrence": oid, "grounded": !grounded })));
@@ -523,6 +529,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
app.start(&id);
}
}
"ui.delete" if item.params.is_some() => crate::delete::request(app, p),
"ui.delete" => delete(app),
"ui.finishSketch" => app.finish_sketch(),
"ui.hide" => {
@@ -649,7 +656,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
/// Delete what is selected: sketch entities, features, and bodies (a Remove feature).
pub fn delete(app: &mut SolveApp) {
crate::viewport::delete_selection(app);
crate::delete::delete_selection(app);
}
/// Add the edges that continue the selected edges smoothly to the selection.
+11
View File
@@ -21,6 +21,7 @@
//! the open menu and its items. `ui.menuPick {item}` runs an item by id or label;
//! `ui.rename {text?, commit?}` finishes the rename box
//! - `ui.window {action: minimize|maximize|restore|toggle|close}`: what the title bar's buttons do
//! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists)
//! - `ui.resize {width, height}`, `app.quit`
use std::sync::mpsc::Sender;
@@ -267,6 +268,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
}
ok(crate::context_menu::describe(app))
}
"ui.confirm" => match p.get("accept").and_then(Value::as_bool) {
Some(a) => {
if crate::delete::confirm(app, a) {
ok(json!({"accepted": a}))
} else {
err("nothing is waiting for a confirmation")
}
}
None => ok(json!({"pending": app.menu.confirm.as_ref().map(|c| c.report.clone())})),
},
"ui.menuPick" => {
let Some(key) = s("item") else { return err("missing `item` (id or label)") };
wrap(crate::context_menu::pick(app, key))
+136
View File
@@ -0,0 +1,136 @@
//! Delete (the Delete and Backspace keys, and every menu's Delete item): whatever is selected in
//! the viewport or the browser goes in one undoable step through `selection.delete`. When it
//! takes other features with it or makes some fail, a confirmation lists them first.
use egui::{RichText, vec2};
use serde_json::{Value, json};
use crate::SolveApp;
use crate::theme::Tokens;
/// A delete waiting for confirmation: its parameters and the dry run's report.
#[derive(Clone, Debug)]
pub struct Pending {
pub params: Value,
pub report: Value,
}
/// What Delete acts on now: the selection (bodies not locked), plus the components picked in
/// the browser (their occurrences).
pub fn selection_params(app: &SolveApp) -> Value {
let items: Vec<&solvecraft_engine::Sel> =
app.session.selection.iter().filter(|s| !matches!(s, solvecraft_engine::Sel::Body { name } if app.ui.locked_bodies.contains(name))).collect();
let occurrences: Vec<u64> = app.tree.picked_components.iter().filter_map(|c| app.session.doc.occurrence_of(*c).map(|o| o.id)).collect();
json!({ "items": items, "occurrences": occurrences, "canvases": app.tree.picked_canvases })
}
fn is_empty(p: &Value) -> bool {
["items", "occurrences", "components", "canvases"].iter().all(|k| p.get(*k).and_then(Value::as_array).is_none_or(|a| a.is_empty()))
}
/// Delete what is selected (the Delete key). A selected sketch dimension or constraint glyph
/// goes first, as before.
pub fn delete_selection(app: &mut SolveApp) {
if crate::dim_view::delete_selected(app) || crate::sketch_tools::delete_glyph(app) {
return;
}
request(app, selection_params(app));
}
/// Delete `params` (as `selection.delete` takes them): at once, or after a confirmation when
/// other features go with it or would fail.
pub fn request(app: &mut SolveApp, params: Value) {
if is_empty(&params) {
return;
}
let mut dry = params.clone();
dry["dry_run"] = json!(true);
let report = match app.session.execute("selection.delete", &dry) {
Ok(r) => r,
Err(e) => {
app.set_status(e.to_string().replace("selection.delete: ", ""), true);
return;
}
};
let asked =
params.get("items").and_then(Value::as_array).map_or(0, |a| a.iter().filter(|i| i["type"] == "feature" || i["type"] == "plane").count());
let extra = report["deleted"].as_array().map_or(0, Vec::len) > asked;
let fails = report["would_fail"].as_array().is_some_and(|a| !a.is_empty());
if extra || fails {
app.menu.confirm = Some(Pending { params, report });
} else {
run(app, &params);
}
}
fn run(app: &mut SolveApp, params: &Value) {
match app.run("selection.delete", params.clone()) {
Ok(r) => {
app.tree.picked_components.clear();
app.tree.picked_canvases.clear();
if let Some(s) = r["skipped"].as_array().and_then(|a| a.first()).and_then(Value::as_str) {
app.set_status(format!("deleted; {s}"), false);
}
}
Err(e) => app.set_status(e.replace("selection.delete: ", ""), true),
}
}
/// Answer the confirmation (also `ui.confirm`): delete, or keep everything.
pub fn confirm(app: &mut SolveApp, accept: bool) -> bool {
let Some(p) = app.menu.confirm.take() else { return false };
if accept {
run(app, &p.params);
}
true
}
/// The confirmation window.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(p) = app.menu.confirm.clone() else { return };
let t = Tokens::get();
let list = |k: &str| -> Vec<String> {
p.report[k].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
};
let (deleted, fails) = (list("deleted"), list("would_fail"));
let mut answer: Option<bool> = None;
egui::Window::new("Delete")
.id(egui::Id::new("sc_delete_confirm"))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -60.0))
.show(ctx, |ui| {
ui.set_max_width(360.0);
if !deleted.is_empty() {
ui.label("These features will be deleted:");
for d in deleted.iter().take(20) {
ui.label(RichText::new(format!(" • {d}")).color(t.text));
}
if deleted.len() > 20 {
ui.label(format!(" … and {} more", deleted.len() - 20));
}
}
if !fails.is_empty() {
ui.add_space(6.0);
ui.label(RichText::new("These features will fail:").color(t.warning));
for f in fails.iter().take(20) {
ui.label(RichText::new(format!(" • {f}")).color(t.warning));
}
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Delete").clicked() {
answer = Some(true);
}
if ui.button("Cancel").clicked() {
answer = Some(false);
}
});
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
answer = Some(false);
}
});
if let Some(a) = answer {
confirm(app, a);
}
}
+2
View File
@@ -12,6 +12,7 @@ pub mod browser;
pub mod canvas;
pub mod context_menu;
pub mod control;
pub mod delete;
pub mod dialogs;
pub mod dim_view;
pub mod gpu;
@@ -606,6 +607,7 @@ impl SolveApp {
dialogs::show(self, ui.ctx());
params_dialog::show(self, ui.ctx());
context_menu::show(self, ui.ctx());
delete::show(self, ui.ctx());
if self.custom_titlebar {
titlebar::resize_zones(ui);
}
+51
View File
@@ -201,3 +201,54 @@ fn browser_folds_are_kept_in_the_preferences() {
other.load_prefs(&prefs);
assert!(other.tree.collapsed.contains("c0/sketches") && other.tree.expanded.contains("origin"));
}
#[test]
fn delete_key_handles_every_kind_and_asks_about_dependents() {
use solvecraft_engine::Sel;
let mut app = sample_app();
app.run("ConstructionPlaneOffsetFromPlaneCommand", json!({"base": "XY", "offset": 10})).unwrap();
app.run("PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [200, 0, 0]})).unwrap();
let n = app.session.doc.features.len();
// A body and a construction plane together: one undo step, no question.
let b = app.session.model.state().bodies.last().unwrap().name.clone();
app.run("select.set", json!({"items": [{"type": "body", "name": b}, {"type": "plane", "name": "Plane1"}]})).unwrap();
crate::delete::delete_selection(&mut app);
assert!(app.menu.confirm.is_none());
assert!(app.session.model.state().body(&b).is_none());
assert!(app.session.doc.find_feature("Plane1").is_none());
app.run("UndoCommand", json!({})).unwrap();
assert_eq!(app.session.doc.features.len(), n);
// A sketch with features built on it: the confirmation lists them first.
let sk = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id;
app.run("select.set", json!({"items": [Sel::Feature { id: sk }]})).unwrap();
crate::delete::delete_selection(&mut app);
let pending = app.menu.confirm.clone().expect("asks first");
assert!(pending.report["deleted"].as_array().unwrap().len() > 1);
assert!(crate::delete::confirm(&mut app, false));
assert_eq!(app.session.doc.features.len(), n, "cancel keeps everything");
crate::delete::delete_selection(&mut app);
crate::delete::confirm(&mut app, true);
assert!(app.session.doc.features.len() < n);
app.run("UndoCommand", json!({})).unwrap();
assert_eq!(app.session.doc.features.len(), n);
// A face alone: not available yet, nothing changes.
let b0 = app.session.model.state().bodies[0].name.clone();
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap();
crate::delete::delete_selection(&mut app);
assert!(app.status.as_ref().is_some_and(|s| s.0.contains("not available yet")), "{:?}", app.status);
assert_eq!(app.session.doc.features.len(), n);
}
#[test]
fn menu_delete_items_use_the_same_path() {
let mut app = sample_app();
let b = body(&app);
app.run("FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]})).unwrap();
let c = app.session.doc.components[0].id;
act(&mut app, &Target::Component { id: c }, "Delete");
assert!(app.session.doc.components.is_empty());
// A sketch from the browser: asks, since its features go too.
let id = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id;
act(&mut app, &Target::Sketch { id }, "Delete");
assert!(app.menu.confirm.is_some());
}
+1 -29
View File
@@ -962,35 +962,7 @@ pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2,
}
pub fn delete_selection(app: &mut SolveApp) {
if crate::dim_view::delete_selected(app) || crate::sketch_tools::delete_glyph(app) {
return;
}
let sel = app.session.selection.clone();
if sel.is_empty() {
return;
}
let mut sketch_ids: Vec<String> = Vec::new();
let mut features: Vec<u64> = Vec::new();
let mut bodies: Vec<String> = Vec::new();
for x in &sel {
match x {
Sel::SketchCurve { id } | Sel::SketchPoint { id } => sketch_ids.push(id.clone()),
Sel::Feature { id } => features.push(*id),
Sel::Body { name } if !app.ui.locked_bodies.contains(name) => bodies.push(name.clone()),
_ => {}
}
}
// Deleting a body removes it from here on in the timeline (a Remove feature).
if !bodies.is_empty() && app.session.active_sketch.is_none() {
let _ = app.run("SoftDeleteCommand", json!({ "bodies": bodies }));
}
if !sketch_ids.is_empty() && app.session.active_sketch.is_some() {
let _ = app.run("sketch.delete", json!({"entities": sketch_ids}));
}
if !features.is_empty() {
let _ = app.run("FusionDeleteCommand", json!({"features": features.iter().map(|f| f.to_string()).collect::<Vec<_>>()}));
}
let _ = app.run("select.clear", json!({}));
crate::delete::delete_selection(app);
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {