From 0ff98d038014d5e2dfef02c0261cc25a58a508e2 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:47:46 +0530 Subject: [PATCH] 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 / Duplicate Selection - Delete / 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 --- crates/ui-egui/src/panels/layers/rows.rs | 91 ++++++++++++++++++++++- crates/ui-egui/src/panels/layers/tests.rs | 85 +++++++++++++++++++++ docs/gaps.md | 5 +- docs/ui-parity.md | 4 +- 4 files changed, 178 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 4e5b7eb218..4dd9d645c2 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -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 = 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 """ 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 """ 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 = 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. diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 0fd8586409..b8f605b460 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -1969,3 +1969,88 @@ fn guide_rows_list_by_index_whatever_the_node_order() { let indexes: Vec = 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 = 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]); +} diff --git a/docs/gaps.md b/docs/gaps.md index d386be463f..d5d5eefeba 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G12: Layers panel row context menu (Options…, Duplicate, Delete, Lock Others/Show All/Hide Others/Unlock All, Locate Object, Collect for Export) with selection handling and undo tracking. Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups ``, or as live text if `/ToUnicode` exists on non-colour fonts (#6740); scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -498,12 +499,12 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Hyphenation › Default Language is the language of new type, unless Document Setup chose one, and each run hyphenates in its own language (Character panel). English and eleven Indian languages have break points; other languages stay whole apart from the Exceptions list (#394, #4947). +- **Done (2026-11-14):** Layers panel row context menu: right-clicking a layer, group, or object row shows a context menu (Options…, Duplicate, Delete, Lock Others / Unlock All, Hide Others / Show All, Locate Object, Collect for Export) with proper selection behavior and Undo/Redo tracking. Tests in `crates/ui-egui/src/panels/layers/tests.rs`. - **Missing:** 2 Preferences options stored but not acted on (#394), each waiting on a subsystem of its own: Plug-ins & Scratch Disks › Primary and Secondary (documents are kept in memory; nothing pages to disk), and Clipboard Handling › Legacy vector clipboard with its Preserve Paths / Preserve Appearance and Overprints choice (Illustrator's own clipboard flavour, which has no public specification). Also Properties - panel per context, context menus beyond the canvas, Layers panel row menus and Collect for Export, SVG - Interactivity. + panel per context, context menus beyond the canvas, SVG Interactivity. - **Impact:** settings that silently do nothing erode trust. - **Estimate:** 13–20 h. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5f10ea4841..fb40a8054e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Layers panel: right-clicking a layer/group/object row shows a context menu with Options…, Duplicate, Delete, Lock/Hide Others, Locate Object, and Collect for Export — matching standard desktop layer panel conventions. Scores unchanged. | | 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638). Scores unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -430,7 +431,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Pathfinder panel › Pathfinder Options…: Precision, Remove Redundant Points, Divide and Outline Will Remove Unpainted Artwork | done | #3426; `object.pathfinder.options`, kept in the preferences; defaults (0.01 pt, both off) keep the earlier results; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) | | Cmd/Ctrl+4 repeats the last successful Shape Mode or Pathfinder operation on the selection | done | Panel operations, direct `object.pathfinder.*` commands and `command.batch` steps (Actions playback) set history; `action.batch`, Trap, compound-shape edits and live effects do not. Repeat uses current Pathfinder Options; [key spec](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [menu spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) | | Pathfinder panel: compound shapes (Make, Release and Expand Compound Shape in the panel menu, Alt-click a Shape Mode, a Shape Mode for selected members, the Expand button) | partial | #4522; `object.compoundShape.make`/`setMode`/`release`/`expand`; members stay editable, the area draws, exports and saves; the button of the members' shared mode is lit; blends, envelopes and warped objects are members as they draw; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html); missing: question marks for mixed modes, hit-testing by the area | -| Canvas context menu | done | Layers rows, panels and type have none (gap) | +| Canvas context menu | done | Panels and type have none (gap) | +| Layers panel row context menu | done | Right-clicking a layer/group/object row shows a context menu with Options…, Duplicate, Delete, Lock/Hide Others, Locate Object, and Collect for Export — matching standard desktop layer panel conventions. | | SVG Interactivity panel: JavaScript for each object's events and the document's linked JavaScript files, written to SVG and read back as text | done | `svgInteractivity.*`, `ui.javascriptFilesDialog` | | Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Blend panel (30.7) | partial | Window › Blend provides Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift, alongside the existing blend controls. Easing is disabled without a spine. Original polynomial distributions approximate the publicly documented behavior; on-canvas easing handles remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) |