diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index af657c0887..0e23e3739f 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -268,6 +268,66 @@ fn disclosure(ui: &Ui, view: &View, n: &Node, (r, x): (egui::Rect, f32), open: b } } +/// The row's name label or inline rename field. Returns (name_end, name_rect). +fn row_name(ui: &mut Ui, view: &View, item: &Row, r: egui::Rect, x: f32, out: &mut Out) -> (f32, egui::Rect) { + let n = &*item.node; + let doc = view.doc; + let t = &view.t; + let xml_id = view.identify_xml_id; + let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "".to_string() } else { n.identify_label(xml_id) }; + let font = egui::FontId::proportional(13.0); + let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id())); + let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0)); + let name_end = match renaming { + Some((rid, mut buf)) if rid == n.id.0 => { + let field = rename_id().with("field"); + let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); + let te = child + .add(egui::TextEdit::singleline(&mut buf).id(field).align(egui::Align2::LEFT_CENTER).desired_width(name_rect.width()).font(font)); + // Focus the field as it opens. Asking every frame took the focus back from Enter, + // Escape or a click elsewhere, so the rename never ended. + if !te.has_focus() && !te.lost_focus() { + te.request_focus(); + } + if te.lost_focus() { + ui.data_mut(|d| d.remove::<(u64, String)>(rename_id())); + if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf}))); + } + } else { + // Shift+Enter renames it and keeps the field open. + if kept && buf != name { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()}))); + } + ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf))); + } + name_rect.right() + } + _ => { + let painter = ui.painter().with_clip_rect(name_rect); + // Generated names ("", "") are translated where painted; the stored name stays English. + // XML IDs stay as ids (not translated). + let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { + tl!("").to_string() + } else if xml_id { + name + } else { + painted_name(n, &n.display_name(), crate::i18n::current()) + }; + let text = paint_name(&painter, egui::pos2(x, r.center().y), &shown, font, if item.dim { t.text_dim } else { t.text }, prints_italic(n)); + // A clipping path's name is underlined, a masked object's with a dashed line. + if item.clip_path { + painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text)); + } else if n.extras().mask.is_some() { + painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0)); + } + text.right() + } + }; + (name_end, name_rect) +} + /// The widgets of row `item`. pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { if let Some(i) = item.guide { @@ -277,8 +337,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { return spine_row(ui, view, item, out); } let n = &*item.node; - let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour); - let doc = view.doc; + let (depth, has_children, open, color) = (item.depth, item.opens, item.open, item.colour); let t = &view.t; let h = view.h; let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); @@ -309,60 +368,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { th }); // Name (Preferences › Units › Identify Objects By: Object Name or XML ID). - let xml_id = view.identify_xml_id; - let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "".to_string() } else { n.identify_label(xml_id) }; - let font = egui::FontId::proportional(13.0); - let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id())); - let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0)); - let name_end; - match renaming { - Some((rid, mut buf)) if rid == n.id.0 => { - let field = rename_id().with("field"); - let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); - let te = child.add( - egui::TextEdit::singleline(&mut buf).id(field).align(egui::Align2::LEFT_CENTER).desired_width(name_rect.width()).font(font.clone()), - ); - // Focus the field as it opens. Asking every frame took the focus back from Enter, - // Escape or a click elsewhere, so the rename never ended. - if !te.has_focus() && !te.lost_focus() { - te.request_focus(); - } - if te.lost_focus() { - ui.data_mut(|d| d.remove::<(u64, String)>(rename_id())); - if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name { - out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf}))); - } - } else { - // Shift+Enter renames it and keeps the field open. - if kept && buf != name { - out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()}))); - } - ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf))); - } - name_end = name_rect.right(); - } - _ => { - let painter = ui.painter().with_clip_rect(name_rect); - // Generated names ("", "") are translated where painted; the stored name stays English. - // XML IDs stay as ids (not translated). - let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { - tl!("").to_string() - } else if xml_id { - name.clone() - } else { - painted_name(n, &n.display_name(), crate::i18n::current()) - }; - let text = paint_name(&painter, egui::pos2(x, r.center().y), &shown, font, if item.dim { t.text_dim } else { t.text }, prints_italic(n)); - name_end = text.right(); - // A clipping path's name is underlined, a masked object's with a dashed line. - if clip_path { - painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text)); - } else if n.extras().mask.is_some() { - painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0)); - } - } - } + let (name_end, name_rect) = row_name(ui, view, item, r, x, out); // Target circle and selection square. let col_x = r.right() - 43.5; ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); @@ -409,6 +415,10 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { // 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}))); + out.rows_after_click = Some(vec![n.id]); + } + if resp.secondary_clicked() { + crate::panels::set_row_keys(ui.ctx(), ROW_KEYS, true); } resp.context_menu(|ui| { row_context_menu(ui, view, n, &collect_ids, out); @@ -462,21 +472,19 @@ fn row_context_menu(ui: &mut Ui, view: &View, n: &Node, collect_ids: &[u64], out } // "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) { + let del_enabled = { + let listed: std::collections::HashSet = collect_ids.iter().map(|&id| NodeId(id)).collect(); + !view.doc.layers.iter().all(|l| listed.contains(&l.id)) + }; + if widgets::menu_item_name(ui, &del_label, del_enabled, 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_cmd, lock_params, lock_enabled) = multi_others_action(view.doc, collect_ids, 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(); @@ -484,14 +492,8 @@ fn row_context_menu(ui: &mut Ui, view: &View, n: &Node, collect_ids: &[u64], out } // Hide Others / Show All Layers { - let (hide_cmd, hide_params) = others_action(view.doc, n, true); + let (hide_cmd, hide_params, hide_enabled) = multi_others_action(view.doc, collect_ids, 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(); @@ -511,6 +513,39 @@ fn row_context_menu(ui: &mut Ui, view: &View, n: &Node, collect_ids: &[u64], out }); } +/// Context menu Others action for `ids`: Lock Others / Unlock All Layers or Hide Others / Show All Layers. +/// When any other layer is unlocked (visible), returns `lockOthers` (`hideOthers`) preserving all layers holding +/// `ids`. When all other layers are locked (hidden) already, returns `unlockAll` (`showAll`). +pub(crate) fn multi_others_action(doc: &Document, ids: &[u64], eye: bool) -> (String, Value, bool) { + let own_layers: std::collections::HashSet = ids + .iter() + .filter_map(|&id| { + let nid = NodeId(id); + let own = doc.layer_of(nid); + doc.node(nid).map(|n| layer_row(n, own)) + }) + .collect(); + let has_other_active = if eye { + doc.layers.iter().any(|l| !own_layers.contains(&l.id) && l.visible) + } else { + doc.layers.iter().any(|l| !own_layers.contains(&l.id) && !l.locked) + }; + let cmd = match (eye, has_other_active) { + (true, true) => "layer.hideOthers", + (true, false) => "layer.showAll", + (false, true) => "layer.lockOthers", + (false, false) => "layer.unlockAll", + }; + let enabled = match (eye, has_other_active) { + (true, true) => true, + (true, false) => doc.layers.iter().any(|l| !l.visible), + (false, true) => true, + (false, false) => doc.layers.iter().any(|l| l.locked), + }; + let params = if cmd.ends_with("Others") { json!({ "ids": ids }) } else { json!({}) }; + (cmd.into(), params, enabled) +} + /// 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 6288754aef..4a6c6ec0b6 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -2462,6 +2462,7 @@ fn context_menu_right_click_selects_unselected_row() { 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"); + assert!(crate::panels::has_row_keys(&ctx, super::ROW_KEYS), "secondary click claims row keys"); } /// Right-clicking a row that IS highlighted preserves the multiselection. @@ -2525,3 +2526,50 @@ fn context_menu_locate_object_locates_by_id() { assert!(result.is_ok(), "layer.locate with id succeeds without selection: {:?}", result.err()); assert_eq!(app.session.active().unwrap().highlighted_rows(), vec![a]); } + +/// In a 1-layer document, the Delete action in the context menu is disabled when targeting that layer. +#[test] +fn context_menu_delete_disabled_when_every_layer_targeted() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + let layer_id = doc.layers[0].id; + + // Targeting every layer disables delete. + let listed: std::collections::HashSet = [layer_id].into_iter().collect(); + let del_enabled = !doc.layers.iter().all(|l| listed.contains(&l.id)); + assert!(!del_enabled, "Delete must be disabled when every layer is targeted"); + + // Add a second layer: delete is now enabled for one layer. + app.session.execute("layer.new", &json!({})).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.layers.len(), 2); + let del_enabled = !doc.layers.iter().all(|l| listed.contains(&l.id)); + assert!(del_enabled, "Delete is enabled when at least one layer remains"); +} + +/// Others actions (Hide Others / Lock Others) retain all multiselected layers. +#[test] +fn context_menu_others_actions_preserve_multiselected_layers() { + 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; + assert_eq!(doc.layers.len(), 3); + let l1 = doc.layers[0].id.0; + let l2 = doc.layers[1].id.0; + + // Hide Others with multiselection [l1, l2] hides only l3. + let (hide_cmd, hide_params, hide_enabled) = super::rows::multi_others_action(doc, &[l1, l2], true); + assert_eq!(hide_cmd, "layer.hideOthers"); + assert!(hide_enabled); + assert_eq!(hide_params["ids"], json!([l1, l2])); + + // Lock Others with multiselection [l1, l2] locks only l3. + let (lock_cmd, lock_params, lock_enabled) = super::rows::multi_others_action(doc, &[l1, l2], false); + assert_eq!(lock_cmd, "layer.lockOthers"); + assert!(lock_enabled); + assert_eq!(lock_params["ids"], json!([l1, l2])); +}