From 60d79e7ea25fa279279f889231a173dcdbf9b4d5 Mon Sep 17 00:00:00 2001 From: MarkusRe <52602624+MarkusReichart@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:37:25 +0000 Subject: [PATCH] Browser: show F2 shortcut on Rename context menu rows and route key when open (#10388) Show F2 beside Rename on context menus where F2 already renames the target (bodies, features and sketches, construction planes, components and occurrences, browser groups, and canvases). Saved views and joints remain without a shortcut label because F2 does not rename them without a menu. When a context menu is open, pressing F2 routes through press_shortcut to rename the menu's target (such as a clicked browser group or canvas) and close the menu, rather than renaming the viewport selection. Co-authored-by: Claude Opus 5.5 Co-authored-by: Gemini 3.8 Flash --- crates/ui-egui/src/browser.rs | 4 +- crates/ui-egui/src/context_menu.rs | 10 ++-- crates/ui-egui/src/menu_tests.rs | 82 ++++++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 6 ++- 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 82300bc0a7..72cc285a97 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -364,7 +364,7 @@ pub fn group_items(app: &SolveApp, id: u64, scope: &str) -> Vec { v.push(Item::action("ui.newGroup", "New Group", "folder").with(json!({ "component": g.component, "folder": g.folder, "parent": id }))); } v.extend([ - Item::action("ui.rename", "Rename", "rename").with(json!({ "group": id })), + Item::action("ui.rename", "Rename", "rename").key("F2").with(json!({ "group": id })), Item::action("ui.ungroup", "Ungroup", "folder").with(json!({ "group": id })), Item::sep(), // The group itself: its items keep their own state. @@ -2181,7 +2181,7 @@ pub fn canvas_items(app: &SolveApp, id: u64) -> Vec { Item::action("canvas.to_sketch", "Canvas to Sketch", "canvas").with(json!({ "canvas": id })), Item::sep(), Item::action("ui.delete", "Delete", "delete").with(json!({ "canvases": [id] })), - Item::action("ui.rename", "Rename", "rename").with(json!({ "canvas": id })), + Item::action("ui.rename", "Rename", "rename").key("F2").with(json!({ "canvas": id })), Item::sep(), Item::action("ui.canvasVisible", "Show/Hide", "eye").key("V").with(json!({ "canvas": id, "visible": !visible })), ] diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index e67b00fda7..2cffcd97be 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -609,7 +609,7 @@ fn feature_items(app: &SolveApp, id: u64) -> Vec { vec![ act("ui.editFeature", "Edit Feature", "edit").with(json!({"feature":id})).on(exists), act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({"sketch": sketch.map(|s| s.0)})).on(sketch.is_some()), - act("ui.rename", "Rename", "rename").with(json!({"feature":id})).on(exists), + act("ui.rename", "Rename", "rename").key("F2").with(json!({"feature":id})).on(exists), act("timeline.suppress", if suppressed { "Unsuppress" } else { "Suppress" }, "suppress") .with(json!({"feature":id, "suppressed": !suppressed})) .on(exists), @@ -788,7 +788,7 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { act("ui.paste", "Paste", "paste").key("Ctrl+V").on(crate::edit_clipboard::can_paste(app)), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": items })).on(!locked), cmd(app, "solid.remove", "Remove").with(json!({ "bodies": names })).on(!locked), - act("ui.rename", "Rename", "rename").with(json!({ "body": bodies.first() })).on(one), + act("ui.rename", "Rename", "rename").key("F2").with(json!({ "body": bodies.first() })).on(one), Item::sep(), act(if all_hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": names })), act("ui.lock", if locked { "Unlock" } else { "Lock" }, "ground").with(json!({ "bodies": names })), @@ -841,7 +841,7 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec { act("ui.copy", "Copy", "copy").key("Ctrl+C").on(crate::edit_clipboard::something_to_copy(app)), act("ui.paste", "Paste", "paste").key("Ctrl+V").on(crate::edit_clipboard::can_paste(app)), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })), - act("ui.rename", "Rename", "rename").with(json!({ "feature": id })), + act("ui.rename", "Rename", "rename").key("F2").with(json!({ "feature": id })), crate::browser::sketches_to_parts_item(sketches).on(parts && !editing), Item::sep(), act("ui.lookAt", "Look At", "lookat").with(json!({ "sketch": id })), @@ -868,7 +868,7 @@ fn plane_items(app: &SolveApp, id: u64) -> Vec { items.push(Item::sep()); items.extend([ act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })), - act("ui.rename", "Rename", "rename").with(json!({ "feature": id })), + act("ui.rename", "Rename", "rename").key("F2").with(json!({ "feature": id })), Item::sep(), act("ui.originPart", "Show/Hide", "eye").key("V").with(json!({ "keys": [f.name], "show": !visible })), Item::sep(), @@ -1083,7 +1083,7 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { if id != 0 { v.push(act("ui.delete", "Delete", "delete").key("Del").with(json!({ "occurrences": [occ.map(|o| o.id)] })).on(occ.is_some())); } - v.push(act("ui.rename", "Rename", "rename").with(json!({ "component": id }))); + v.push(act("ui.rename", "Rename", "rename").key("F2").with(json!({ "component": id }))); v.push(Item::sep()); v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": names })).on(!bodies.is_empty())); v.push(act("ui.showAll", "Show All Bodies", "eye")); diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 50c819cf46..468828919f 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -1057,6 +1057,88 @@ fn f2_renames_the_selected_item() { assert!(!context_menu::rename_selection(&mut app, pos2(0.0, 0.0)), "nothing selected"); } +/// Rename shows F2 wherever F2 renames (bodies, sketches and features, construction geometry, +/// components and placements, groups, canvases), as Cut, Copy, Delete and Show/Hide show their +/// keys; joints and saved views, which F2 does not rename, show none. F2 pressed with such a +/// menu open renames what the menu is for, as its row says: a group or canvas is not selected by +/// the right-click that opens its menu, so F2 would rename the selection instead. +#[test] +fn rename_shows_f2_and_f2_with_a_menu_open_renames_what_the_menu_is_for() { + use crate::context_menu::RenameWhat; + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 10, "width": 20, "height": 30})).unwrap(); + let b = body(&h.app); + let feature = h.app.session.doc.features[0].id; + h.app.run("construct.plane.offset", json!({"base": "XY", "offset": 5})).unwrap(); + let plane = h.app.session.doc.features.last().unwrap().id; + let part = h.app.run("component.create", json!({"name": "Menu part"})).unwrap()["component"].as_u64().unwrap(); + let occurrence = h.app.session.doc.occurrence_of(part).unwrap().id; + let group = h.app.run("browser.group", json!({"component": 0, "folder": "bodies", "items": [b]})).unwrap()["group"].as_u64().unwrap(); + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + png.extend(400u32.to_be_bytes()); + png.extend(200u32.to_be_bytes()); + png.extend([8, 6, 0, 0, 0, 0, 0, 0, 0]); + let data = solidcraft_engine::doc::canvas::base64_encode(&png); + let canvas = h.app.run("canvas.insert", json!({"data": data, "plane": "XY", "width": 80})).unwrap()["canvas"].as_u64().unwrap(); + let rename_key = |app: &SolveApp, target: &Target| { + context_menu::items(app, target) + .into_iter() + .find(|i| i.id == "ui.rename") + .map(|i| i.shortcut) + .unwrap_or_else(|| panic!("no Rename row: {target:?}")) + }; + for target in [ + Target::Body { name: b.clone() }, + Target::Sketch { id: feature }, + Target::Feature { id: feature }, + Target::Plane { id: plane }, + Target::Component { id: part }, + Target::Occurrence { path: vec![occurrence] }, + Target::Group { id: group, scope: "c0".into() }, + Target::Canvas { id: canvas }, + ] { + assert_eq!(rename_key(&h.app, &target), "F2", "Rename shows F2: {target:?}"); + } + assert_eq!(rename_key(&h.app, &Target::NamedView { name: "Top".into() }), "", "F2 does not rename a saved view"); + let sample = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Hinge Assembly").unwrap(); + let mut assembly = SolveApp::new(Session::default(), Services::default()); + assembly.session.run_script(&(sample.script)()).unwrap(); + let joint = assembly.session.doc.assembly.joints[0].id; + assert_eq!(rename_key(&assembly, &Target::Joint { id: joint }), "", "F2 does not rename a joint"); + + let press_f2 = |h: &mut crate::scenario::Harness| { + for pressed in [true, false] { + h.app.synthetic.push(egui::Event::Key { key: egui::Key::F2, physical_key: None, pressed, repeat: false, modifiers: Default::default() }); + } + while !h.app.synthetic.is_empty() { + h.frame(); + } + }; + // A body selected elsewhere is not what the group's or the canvas's menu is for. + h.app.run("select.set", json!({"items": [{"type": "body", "name": b}]})).unwrap(); + for (target, what) in + [(Target::Group { id: group, scope: "c0".into() }, RenameWhat::Group(group)), (Target::Canvas { id: canvas }, RenameWhat::Canvas(canvas))] + { + context_menu::open_for(&mut h.app, pos2(300.0, 200.0), target.clone()); + h.frames(2); + press_f2(&mut h); + let renaming = h.app.menu.rename.as_ref().map(|r| r.what.clone()); + assert_eq!(renaming, Some(what), "F2 on the menu of {target:?}"); + assert!(h.app.viewport.context_menu.is_none(), "the menu closes as after a click"); + h.step(&json!({"key": "Escape"})).unwrap(); + h.frames(2); + assert!(h.app.menu.rename.is_none()); + } + // With no menu open, F2 still renames the selection. + h.app.run("select.set", json!({"items": [{"type": "body", "name": b}]})).unwrap(); + h.frames(2); + press_f2(&mut h); + assert_eq!(h.app.menu.rename.as_ref().map(|r| r.what.clone()), Some(RenameWhat::Body(b)), "F2 without a menu renames the selection"); + h.step(&json!({"key": "Escape"})).unwrap(); + h.frames(2); + assert!(h.app.menu.rename.is_none()); +} + /// A double-click on a body's browser row edits its name in the row, like F2 (no floating box); /// Escape cancels, and a second double-click then commits a new name. #[test] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6d6d35c9d5..5808ed5881 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -3181,8 +3181,10 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } } Key::F2 => { - let at = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(ctx.content_rect().center()); - crate::context_menu::rename_selection(app, at); + if !crate::context_menu::press_shortcut(app, "F2") { + let at = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(ctx.content_rect().center()); + crate::context_menu::rename_selection(app, at); + } } Key::F6 => app.animate_view("fit"), Key::Backspace if crate::tools::undo_spline_point(app) => {}