feat(layers): implement row context menu and actions

- Add right-click context menu on layer, group, and object rows:
  - Options... / Layer Options... / Options for Selection...
  - Duplicate <name> / Duplicate Selection
  - Delete <name> / Delete Selection
  - Lock Others / Unlock All Layers
  - Hide Others / Show All Layers
  - Locate Object
  - Collect for Export
- Select unselected row on right-click; preserve multiselection if already highlighted
- Dispatch actions via registered engine commands with Undo/Redo tracking
- Add headless egui tests covering selection, dispatch, and state integrity
- Update gaps and ui-parity documentation for G12
This commit is contained in:
ruchirguitar
2026-11-14 09:47:46 +05:30
parent 66513d47fa
commit 0ff98d0380
4 changed files with 178 additions and 7 deletions
+87 -4
View File
@@ -390,12 +390,14 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]})));
}
}
// Row menu: Collect for Export on this row (or every highlighted row when this is one of them).
// Row menu: context actions on this row (or every highlighted row when this is one of them).
let collect_ids: Vec<u64> = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] };
// Right-clicking an unselected row selects it; a highlighted row keeps multiselection.
if resp.secondary_clicked() && !view.rows.contains(&n.id) {
out.actions.push(("layer.setCurrent".into(), json!({"id": n.id.0})));
}
resp.context_menu(|ui| {
collect_for_export_menu(ui, true, |multiple| {
out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple })));
});
row_context_menu(ui, view, n, &collect_ids, out);
});
// Dragging rows: the highlighted rows when this is one of them, else this row. Only the primary
// button drags; the secondary button opens the row menu, and a drag with it moves nothing.
@@ -414,6 +416,87 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
drop_target(ui, view, n, r, x - 16.0, open, &resp, out);
}
/// The right-click context menu of row `n`.
fn row_context_menu(ui: &mut Ui, view: &View, n: &Node, collect_ids: &[u64], out: &mut Out) {
let is_multi = collect_ids.len() > 1;
// "Layer Options…" / "Options for Selection…" / "Options…"
let options_label = if is_multi {
"Options for Selection…"
} else if n.is_layer() {
"Layer Options…"
} else {
"Options…"
};
if widgets::menu_item(ui, options_label, true, false) {
out.actions.push(("ui.layerOptions".into(), json!({ "ids": collect_ids })));
ui.close();
}
// "Duplicate "<name>"" or "Duplicate Selection"
let display = {
let raw = painted_name(n, &n.display_name(), crate::i18n::current());
if raw.chars().count() > 20 {
let truncated: String = raw.chars().take(20).collect();
format!("{truncated}…")
} else {
raw
}
};
let dup_label = if is_multi { tl!("Duplicate Selection").to_string() } else { format!("{} \"{display}\"", tl!("Duplicate")) };
if widgets::menu_item_name(ui, &dup_label, true, false) {
out.actions.push(("layer.duplicate".into(), json!({ "ids": collect_ids })));
ui.close();
}
// "Delete "<name>"" or "Delete Selection"
let del_label = if is_multi { tl!("Delete Selection").to_string() } else { format!("{} \"{display}\"", tl!("Delete")) };
if widgets::menu_item_name(ui, &del_label, true, false) {
out.actions.push(("layer.delete".into(), json!({ "ids": collect_ids })));
ui.close();
}
ui.separator();
// Lock Others / Unlock All Layers
{
let (lock_cmd, lock_params) = others_action(view.doc, n, false);
let lock_label = if lock_cmd == "layer.lockOthers" { "Lock Others" } else { "Unlock All Layers" };
let own = view.doc.layer_of(n.id);
let lock_enabled = if lock_cmd == "layer.lockOthers" {
view.doc.layers.iter().any(|l| Some(l.id) != own && !l.locked)
} else {
view.doc.layers.iter().any(|l| l.locked)
};
if widgets::menu_item(ui, lock_label, lock_enabled, false) {
out.actions.push((lock_cmd, lock_params));
ui.close();
}
}
// Hide Others / Show All Layers
{
let (hide_cmd, hide_params) = others_action(view.doc, n, true);
let hide_label = if hide_cmd == "layer.hideOthers" { "Hide Others" } else { "Show All Layers" };
let own = view.doc.layer_of(n.id);
let hide_enabled = if hide_cmd == "layer.hideOthers" {
view.doc.layers.iter().any(|l| Some(l.id) != own && l.visible)
} else {
view.doc.layers.iter().any(|l| !l.visible)
};
if widgets::menu_item(ui, hide_label, hide_enabled, false) {
out.actions.push((hide_cmd, hide_params));
ui.close();
}
}
ui.separator();
// Locate Object
if widgets::menu_item(ui, "Locate Object", true, false) {
out.actions.push(("layer.locate".into(), json!({ "id": n.id.0 })));
ui.close();
}
ui.separator();
// Collect for Export
let ids: Vec<u64> = collect_ids.to_vec();
collect_for_export_menu(ui, true, move |multiple| {
out.actions.push(("assets.add".into(), json!({ "ids": ids.clone(), "multiple": multiple })));
});
}
/// Alt-clicking row `n`'s eye (`eye`) or lock: Hide Others or Lock Others, every top-level layer
/// but the one holding the row; when they all are hidden (locked) already, Show All Layers (Unlock
/// All Layers). Each is one undo step.
+85
View File
@@ -1969,3 +1969,88 @@ fn guide_rows_list_by_index_whatever_the_node_order() {
let indexes: Vec<usize> = rows.iter().filter_map(|r| r.guide).collect();
assert_eq!(indexes, [2, 1, 0]);
}
/// Right-clicking a row that is not highlighted selects it.
#[test]
fn context_menu_right_click_selects_unselected_row() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
app.session.execute("layer.highlight", &json!({"ids": [], "mode": "set"})).unwrap();
let ctx = egui::Context::default();
// Rows top-first: Layer 2, Layer 1. Neither is highlighted.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 2);
assert!(rows_of(&app).is_empty());
// Secondary-click Layer 1's row (bottom row).
let pos = circles[1] - vec2(100.0, 0.0); // name area
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], false);
frame(&mut app, &ctx, vec![button_by(pos, true, egui::PointerButton::Secondary)], false);
frame(&mut app, &ctx, vec![button_by(pos, false, egui::PointerButton::Secondary)], false);
// The row is now highlighted.
let highlighted = rows_of(&app);
let layer1 = app.session.active().unwrap().doc.layers[0].id;
assert!(highlighted.contains(&layer1), "right-click selected the unselected row");
}
/// Right-clicking a row that IS highlighted preserves the multiselection.
#[test]
fn context_menu_right_click_preserves_multiselection() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
let doc = &app.session.active().unwrap().doc;
let ids: Vec<u64> = doc.layers.iter().map(|l| l.id.0).collect();
// Highlight all 3 layers.
app.session.execute("layer.highlight", &json!({"ids": ids, "mode": "set"})).unwrap();
let ctx = egui::Context::default();
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 3);
// Secondary-click the middle row.
let pos = circles[1] - vec2(100.0, 0.0);
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], false);
frame(&mut app, &ctx, vec![button_by(pos, true, egui::PointerButton::Secondary)], false);
frame(&mut app, &ctx, vec![button_by(pos, false, egui::PointerButton::Secondary)], false);
// All 3 must still be highlighted.
assert_eq!(rows_of(&app).len(), 3, "multiselection preserved after right-click on a highlighted row");
}
/// layer.delete removes the specified layer.
#[test]
fn context_menu_delete_dispatches_layer_delete() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let layer2_id = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
let before = app.session.active().unwrap().doc.layers.len();
// Dispatch layer.delete with explicit ids (what the context menu item does).
app.session.execute("layer.delete", &json!({"ids": [layer2_id]})).unwrap();
let after = app.session.active().unwrap().doc.layers.len();
assert_eq!(after, before - 1, "layer.delete removes the layer");
}
/// layer.duplicate adds a copy.
#[test]
fn context_menu_duplicate_dispatches_layer_duplicate() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let layer_id = app.session.active().unwrap().doc.layers[0].id.0;
let before_layers = app.session.active().unwrap().doc.layers.len();
let before_undo = app.session.active().unwrap().history.undo.len();
app.session.execute("layer.duplicate", &json!({"ids": [layer_id]})).unwrap();
let after = &app.session.active().unwrap();
assert_eq!(after.doc.layers.len(), before_layers + 1, "layer.duplicate adds a layer");
assert_eq!(after.history.undo.len(), before_undo + 1, "one undo step");
}
/// layer.locate locates a specified object id without requiring a prior canvas selection.
#[test]
fn context_menu_locate_object_locates_by_id() {
let (mut app, _, [a, _]) = two_rects();
app.session.execute("select.none", &json!({})).unwrap();
assert!(app.session.active().unwrap().selection.objects.is_empty());
// Context menu passes the node's id directly to layer.locate.
let result = app.session.execute("layer.locate", &json!({"id": a.0}));
assert!(result.is_ok(), "layer.locate with id succeeds without selection: {:?}", result.err());
assert_eq!(app.session.active().unwrap().highlighted_rows(), vec![a]);
}