mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 15:54:53 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 `<g>`, 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.
|
||||
|
||||
|
||||
+3
-1
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user