diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34dd61442..6e7e052c4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -320,6 +320,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t); ruler_units_menus(app, &resp, full, rect, &doc); } + context_menu(app, ui, &resp); if let Some(r) = sel_rect && !preview && r.intersects(screen) @@ -1336,6 +1337,86 @@ fn ruler_units_menu(app: &mut DesignApp, ui: &mut egui::Ui, key: &str, cur: desi } } +/// A canvas context menu asked for by a right-click; `open` until the popup has opened. +#[derive(Clone, Copy, Debug)] +struct MenuRequest { + kind: crate::menus::ContextMenu, + open: bool, +} + +/// The popup id of the canvas context menu in split-view pane `pane`. +fn context_menu_id(pane: u8) -> egui::Id { + egui::Id::new(("canvas_context_menu", pane)) +} + +/// Is a canvas context menu open (in either pane)? +pub fn context_menu_open(ctx: &egui::Context) -> bool { + (0..2).any(|pane| egui::Popup::is_id_open(ctx, context_menu_id(pane))) +} + +/// Shows the context menu a right-click asked for. It opens a frame after the click: a click +/// while the menu is open closes it in that frame, and the menu then reopens where the new click was. +fn context_menu(app: &mut DesignApp, ui: &egui::Ui, resp: &egui::Response) { + let id = context_menu_id(app.pane); + let Some(req) = ui.data(|d| d.get_temp::(id)) else { return }; + let open = if req.open { + ui.data_mut(|d| d.insert_temp(id, MenuRequest { open: false, ..req })); + Some(egui::SetOpenCommand::Bool(true)) + } else { + None + }; + egui::Popup::menu(resp) + .id(id) + .open_memory(open) + .at_pointer_fixed() + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| crate::menus::context_menu(app, ui, req.kind)); +} + +/// Prepares a right-click at canvas point `p`: picks the menu and the selection it acts on. +/// - Typing: a click in a text frame places the caret there as a left click would, except +/// inside the selected text, which stays selected; elsewhere the text is left as it is. +/// - On an object: it is selected first unless it already is, as the Selection tool would. +/// - On nothing: the selection stays. +fn context_target(app: &mut DesignApp, p: Point, zoom: f64) -> crate::menus::ContextMenu { + use crate::menus::ContextMenu; + let hit = item_at(app, p, zoom); + if app.session.wants_text() { + let text_frame = hit.filter(|(id, _)| app.session.active().and_then(|st| st.doc.item(*id)).is_some_and(Item::is_text_frame)); + if let Some((id, sp)) = text_frame { + // A press in the selected text keeps it for dragging; end that press without a move. + if app.run("text.placeCaret", json!({"frame": id.0, "point": [sp.x, sp.y]})).is_ok_and(|r| r["dragging"] == json!(true)) { + let _ = app.run("text.release", json!({"frame": id.0, "point": [sp.x, sp.y], "moved": true})); + } + } + return ContextMenu::Text; + } + let Some((id, _)) = hit else { return ContextMenu::Canvas }; + let direct = app.session.tool_id() == "directSelection"; + let Some((target, selected)) = app.session.active().map(|st| { + let target = if direct { id } else { st.doc.top_level_of(id).unwrap_or(id) }; + (target, st.selection.contains(target)) + }) else { + return ContextMenu::Canvas; + }; + if !selected { + let _ = app.run("selection.set", json!({"ids": [target.0], "content": direct})); + } + ContextMenu::Object +} + +/// The frontmost item under canvas point `p` and that point in its spread, hit as the tools do. +fn item_at(app: &DesignApp, p: Point, zoom: f64) -> Option<(designcraft_doc::ItemId, Point)> { + let st = app.session.active()?; + let (sr, sp) = CanvasLayout::new(&st.doc, st.editing_parents).spread_at(p)?; + let tol = 4.0 / zoom.max(1e-6); + let id = match sr { + SpreadRef::Doc(si) => st.doc.hit_item(si, sp, tol)?, + _ => st.doc.spread(sr)?.items.iter().rev().find(|it| !it.locked && designcraft_doc::edit_hit(it, sp, tol))?.id, + }; + Some((id, sp)) +} + fn fmt_tick(v: f64) -> String { let r = v.round(); if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } @@ -1346,6 +1427,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let xf = Xf::new(rect, &v); let wants_text = app.session.wants_text(); let space = ui.input(|i| i.key_down(egui::Key::Space)) && !wants_text && !ui.ctx().text_edit_focused(); + // While the context menu is open, the click that closes it and the keys go to the menu. + let menu_open = egui::Popup::is_id_open(ui.ctx(), context_menu_id(app.pane)); // Scroll / zoom. if resp.hovered() { let (scroll, zoom_delta, m, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.pointer.hover_pos())); @@ -1374,7 +1457,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r return; } // Ruler guides need the rulers (hidden while the view is rotated). - if !space && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) { + if !space && !menu_open && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) { return; } let m = ui.input(|i| mods(i, space)); @@ -1392,9 +1475,11 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r i.pointer.button_double_clicked(egui::PointerButton::Primary), ) }); + let in_view = |o: Pos2| if xf.rot == 0 { rect.contains(o) } else { resp.rect.contains(o) }; if pressed && !space - && let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered()) + && !menu_open + && let Some(o) = origin.filter(|o| in_view(*o) && resp.hovered()) { events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m }); down = true; @@ -1440,11 +1525,22 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } } app.after_engine(); + // Right-click: tools only see the primary button, so it starts no gesture; it opens the menu. + if resp.secondary_clicked() + && !down + && !space + && app.ui.screen_mode != ScreenMode::Presentation + && let Some(o) = resp.interact_pointer_pos().filter(|o| in_view(*o)) + { + let kind = context_target(app, xf.to_canvas(o), xf.zoom); + ui.data_mut(|d| d.insert_temp(context_menu_id(app.pane), MenuRequest { kind, open: true })); + ui.ctx().request_repaint(); + } if resp.clicked() || resp.drag_started() { resp.request_focus(); } // Keyboard for the active tool. - if ui.ctx().text_edit_focused() && !resp.has_focus() { + if menu_open || (ui.ctx().text_edit_focused() && !resp.has_focus()) { return; } let evs = ui.input(|i| i.events.clone()); @@ -1798,6 +1894,117 @@ mod tests { } } + /// Screen position of spread 0's point (x, y). + fn screen_of(h: &Harness<'_, DesignApp>, x: f64, y: f64) -> Pos2 { + let app = h.state(); + let st = app.session.active().unwrap(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + xf.to_screen(CanvasLayout::new(&st.doc, st.editing_parents).xf(SpreadRef::Doc(0)) * Point::new(x, y)) + } + + /// A menu row: its label, then its shortcut if it has one. + fn menu_row<'h>(h: &'h Harness<'_, DesignApp>, label: &'h str) -> Option> { + h.query_by(move |n| { + n.role() == egui::accesskit::Role::Button + && n.label().is_some_and(|l| l == label || l.strip_prefix(label).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| !r.contains(' '))) + }) + } + + fn enabled(h: &Harness<'_, DesignApp>, label: &str) -> bool { + use egui_kittest::kittest::NodeT; + !menu_row(h, label).unwrap_or_else(|| panic!("no menu row {label}")).accesskit_node().is_disabled() + } + + fn spread_items(h: &Harness<'_, DesignApp>) -> Vec { + h.state().session.doc().unwrap().doc.spreads[0].items.iter().map(|i| i.id.0).collect() + } + + fn selected(h: &Harness<'_, DesignApp>) -> Vec { + h.state().session.doc().unwrap().selection.items.iter().map(|i| i.0).collect() + } + + #[test] + fn right_clicking_an_object_selects_it_and_its_menu_arranges_it() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [36, 36, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [150, 150, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + let mut h = harness(app); + assert_eq!(selected(&h), [b]); + let on_a = screen_of(&h, 60.0, 60.0); + right_click(&mut h, on_a); + assert_eq!(selected(&h), [a], "the object under the pointer is selected first"); + // An empty frame's menu: Content, but no Fitting (nothing to fit) and no text frame entries. + assert!(menu_row(&h, "Content").is_some()); + assert!(menu_row(&h, "Fitting").is_none() && menu_row(&h, "Text Frame Options…").is_none()); + assert!(enabled(&h, "Copy") && !enabled(&h, "Paste"), "nothing copied yet: Paste is greyed"); + menu_row(&h, "Arrange").unwrap().click(); + h.run_steps(3); + menu_row(&h, "Bring to Front").unwrap().click(); + h.run_steps(3); + assert_eq!(spread_items(&h), [b, a]); + assert!(menu_row(&h, "Arrange").is_none(), "choosing an entry closes the menu"); + } + + #[test] + fn right_clicking_empty_canvas_offers_paste_after_a_copy() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200]})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + app.run("edit.deselectAll", json!({})).unwrap(); + let mut h = harness(app); + let empty = screen_of(&h, 400.0, 500.0); + right_click(&mut h, empty); + assert!(selected(&h).is_empty()); + assert!(menu_row(&h, "Arrange").is_none(), "the page menu, not the object menu"); + assert!(enabled(&h, "Paste in Place")); + assert!(menu_row(&h, "✓ Show/Hide Rulers").is_some(), "toggles show their state"); + // Escape closes the menu and does nothing else. + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(menu_row(&h, "Paste in Place").is_none()); + assert_eq!(h.state().session.tool_id(), "selection"); + right_click(&mut h, empty); + menu_row(&h, "Paste").unwrap().click(); + h.run_steps(3); + assert_eq!(spread_items(&h).len(), 2); + } + + #[test] + fn a_secondary_drag_neither_moves_marquees_nor_draws() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [36, 36, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let mut h = harness(app); + // A secondary-button drag between spread points. + let drag = |h: &mut Harness<'_, DesignApp>, from: (f64, f64), to: (f64, f64)| { + let (from, to) = (screen_of(h, from.0, from.1), screen_of(h, to.0, to.1)); + h.hover_at(from); + h.step(); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.step(); + for t in [0.25, 0.5, 0.75, 1.0] { + h.event(egui::Event::PointerMoved(from + (to - from) * t)); + h.step(); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + }; + let xf = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(a)).unwrap().xf; + let before = xf(&h); + // Selection tool: on the object (no move), and from empty page across it (no marquee). + drag(&mut h, (100.0, 100.0), (300.0, 300.0)); + assert_eq!(xf(&h), before); + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + drag(&mut h, (400.0, 400.0), (20.0, 20.0)); + assert!(selected(&h).is_empty()); + // Rectangle Frame tool: no frame. + h.state_mut().select_tool("rectangleFrame"); + drag(&mut h, (300.0, 300.0), (500.0, 500.0)); + assert_eq!(spread_items(&h), [a]); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..53e1360a4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2197,6 +2197,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "逆时针旋转 90°", ], ), + ("Rotate 180°", ["180° drehen", "Rotation à 180°", "Girar 180°", "180°回転", "旋转 180°"]), ("Rotation Angle", ["Rotation Angle", "Rotation Angle", "Rotation Angle", "Rotation Angle", "旋转角度"]), ("Rows", ["Rows", "Rows", "Rows", "Rows", "行数"]), ("Run", ["Run", "Run", "Run", "Run", "运行"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..f06489c9e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -495,6 +495,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Release to Objects", "تحرير إلى كائنات"), ("Reverse the gradient", "عكس التدرج"), ("Rotate 90° Counterclockwise", "تدوير 90° عكس عقارب الساعة"), + ("Rotate 180°", "تدوير 180°"), ("Rotation Angle", "زاوية الدوران"), ("Row Height", "ارتفاع الصف"), ("Run", "تشغيل"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6ba..f82c06db4 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1094,6 +1094,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Release to Objects", "Rilascia in oggetti"), ("Remove", "Rimuovi"), ("Rotate 90° Counterclockwise", "Ruota di 90° in senso antiorario"), + ("Rotate 180°", "Ruota di 180°"), ("Rotation Angle", "Angolo di rotazione"), ("Rows", "Righe"), ("Run", "Esegui"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4da..f6ccd542a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1093,6 +1093,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Release to Objects", "個別のオブジェクトに分離"), ("Remove", "削除"), ("Rotate 90° Counterclockwise", "反時計回りに90°回転"), + ("Rotate 180°", "180°回転"), ("Rotation Angle", "回転角度"), ("Rows", "行数"), ("Run", "実行"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc6..74b2b9e42 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -494,6 +494,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Release to Objects", "Liberar para objetos"), ("Reverse the gradient", "Inverter o gradiente"), ("Rotate 90° Counterclockwise", "Girar 90° no sentido anti-horário"), + ("Rotate 180°", "Girar 180°"), ("Rotation Angle", "Ângulo de rotação"), ("Row Height", "Altura da linha"), ("Run", "Executar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89ba..d2fa284a8 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1091,6 +1091,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Release to Objects", "Перетворити на окремі об’єкти"), ("Remove", "Прибрати"), ("Rotate 90° Counterclockwise", "Повернути на 90° проти годинникової стрілки"), + ("Rotate 180°", "Повернути на 180°"), ("Rotation Angle", "Кут повороту"), ("Rows", "Рядки"), ("Run", "Запустити"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 94e5c0971..7e37c5c2b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1970,24 +1970,176 @@ pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) { menus.reverse(); } for (menu, entries) in menus { - menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&lang, menu)), |ui| { - if crate::i18n::is_rtl(&lang) { - ui.set_max_width(320.0); - } - ui.with_layout(egui::Layout::top_down_justified(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| { - ui.set_min_width(240.0); - let hidden = menu_items(app, ui, &entries, menu); - if hidden > 0 && !app.ui.show_full_menus { - ui.separator(); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show All Menu Items"))).clicked() { - app.ui.show_full_menus = true; - } - } - }); - }); + menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&lang, menu)), |ui| menu_column(app, ui, &entries, menu)); } } +/// One menu's rows, as the menu bar and the canvas context menus show them. +fn menu_column(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str) { + let lang = app.ui.language.clone(); + let rtl = crate::i18n::is_rtl(&lang); + if rtl { + ui.set_max_width(320.0); + } + ui.with_layout(egui::Layout::top_down_justified(if rtl { egui::Align::Max } else { egui::Align::Min }), |ui| { + ui.set_min_width(240.0); + let hidden = menu_items(app, ui, items, path); + if hidden > 0 && !app.ui.show_full_menus { + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show All Menu Items"))).clicked() { + app.ui.show_full_menus = true; + } + } + }); +} + +/// What a right-click on the canvas opens a menu for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContextMenu { + /// The selected objects. + Object, + /// The text being edited with the Type tool. + Text, + /// The page or pasteboard, where nothing was hit. + Canvas, +} + +// Canvas context menus, in the menu bar's entry syntax; `menu:Menu/Submenu` inserts that menu bar +// submenu as it is. +const CONTEXT_OBJECT: &[&str] = &[ + "cmd:edit.undo", + "cmd:edit.redo", + "-", + "cmd:edit.cut", + "cmd:edit.copy", + "cmd:edit.paste", + "cmd:edit.pasteInto", + "cmd:edit.pasteInPlace", + "-", + ">Transform", + "cmd:transform.rotate|Rotate 90° Clockwise|{\"angle\": -90}", + "cmd:transform.rotate|Rotate 90° Counterclockwise|{\"angle\": 90}", + "cmd:transform.rotate|Rotate 180°|{\"angle\": 180}", + "-", + "cmd:transform.flip|Flip Horizontal|{\"axis\": \"horizontal\"}", + "cmd:transform.flip|Flip Vertical|{\"axis\": \"vertical\"}", + "-", + "cmd:transform.clear", + "<", + "menu:Object/Arrange", + "menu:Object/Select", + "-", + "cmd:object.group", + "cmd:object.ungroup", + "cmd:object.lock", + "cmd:object.unlockAll", + "cmd:object.hide", + "cmd:object.showAll", + "-", +]; +/// Added to the object menu when a graphic frame is selected. +const CONTEXT_GRAPHIC_FRAME: &[&str] = &["menu:Object/Fitting"]; +const CONTEXT_OBJECT_TAIL: &[&str] = &["menu:Object/Content", "menu:Object/Effects"]; +/// Added to the object menu when a text frame is selected. +const CONTEXT_TEXT_FRAME: &[&str] = &["-", "cmd:type.fillWithPlaceholder", "cmd:object.textFrameOptions"]; +const CONTEXT_TEXT: &[&str] = &[ + "cmd:edit.cut", + "cmd:edit.copy", + "cmd:edit.paste", + "cmd:edit.pasteWithoutFormatting", + "-", + "menu:Type/Insert Special Character", + "menu:Type/Insert White Space", + "menu:Type/Insert Break Character", + "cmd:type.fillWithPlaceholder", + "-", + "menu:Type/Change Case", + "-", + "ui:view.hiddenCharacters", + "ui:app.storyEditor", + "-", + "cmd:edit.selectAll", +]; +const CONTEXT_CANVAS: &[&str] = &[ + "cmd:edit.undo", + "cmd:edit.redo", + "-", + "cmd:edit.paste", + "cmd:edit.pasteInPlace", + "-", + "ui:view.zoomIn", + "ui:view.zoomOut", + "ui:view.fitPage", + "ui:view.fitSpread", + "ui:view.actualSize", + "-", + "ui:view.rulers", + "menu:View/Grids & Guides", + "-", + "cmd:edit.selectAll", +]; + +/// The menu-bar path context menu rows are keyed by (Edit › Menus doesn't list them). +const CONTEXT_PATH: &str = "Context"; + +/// The entries of a canvas context menu for the current selection. +fn context_entries(app: &DesignApp, kind: ContextMenu) -> Vec<&'static str> { + match kind { + ContextMenu::Object => { + let (graphic, text) = app.session.active().map_or((false, false), |st| { + let items = || st.selection.items.iter().filter_map(|i| st.doc.item(*i)); + (items().any(|it| matches!(it.content, designcraft_doc::Content::Graphic(_))), items().any(|it| it.is_text_frame())) + }); + let mut v = CONTEXT_OBJECT.to_vec(); + if graphic { + v.extend(CONTEXT_GRAPHIC_FRAME); + } + v.extend(CONTEXT_OBJECT_TAIL); + if text { + v.extend(CONTEXT_TEXT_FRAME); + } + v + } + ContextMenu::Text => CONTEXT_TEXT.to_vec(), + ContextMenu::Canvas => CONTEXT_CANVAS.to_vec(), + } +} + +/// Parses context menu entries: the menu bar's syntax plus `menu:Menu/Submenu` (not inside a `>` submenu). +fn context_parse(entries: &[&str]) -> Vec { + let tree = menu_tree(); + let mut out = Vec::new(); + let mut run: Vec<&str> = Vec::new(); + for e in entries { + if let Some(path) = e.strip_prefix("menu:") { + out.extend(parse_entries(&std::mem::take(&mut run))); + out.extend(menu_bar_submenu(&tree, path)); + } else { + run.push(e); + } + } + out.extend(parse_entries(&run)); + out +} + +/// The menu bar's submenu at `Menu/Submenu`. +fn menu_bar_submenu(tree: &[(&str, Vec)], path: &str) -> Option { + let (menu, sub) = path.split_once('/')?; + tree.iter().find(|(m, _)| *m == menu)?.1.iter().find(|it| matches!(it, Item::Sub(name, _) if name == sub)).cloned() +} + +/// A canvas context menu: the menu bar's rows (labels, shortcuts, check marks, greyed when they +/// can't run), scrolling when taller than the window. +pub fn context_menu(app: &mut DesignApp, ui: &mut egui::Ui, kind: ContextMenu) { + let items = context_parse(&context_entries(app, kind)); + let max_height = menu_max_height(ui, None); + let mut scroll = egui::ScrollArea::vertical().max_height(max_height).min_scrolled_height(1.0); + if ui.is_sizing_pass() { + scroll = scroll.vertical_scroll_offset(0.0); + } + scroll.show(ui, |ui| menu_column(app, ui, &items, CONTEXT_PATH)); +} + /// The key a menu item is hidden by (Edit › Menus). pub fn menu_key(menu: &str, label: &str) -> String { format!("{menu}/{label}") @@ -2080,7 +2232,8 @@ fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { // Only a focused text field takes the keys: the canvas (or a button) having focus after a click // must not swallow tool shortcuts until Esc clears it. - if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() { + // An open canvas context menu takes the keys (Escape closes it), as a native menu would. + if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() || crate::canvas::context_menu_open(ctx) { return; } let typing = app.session.wants_text(); @@ -3059,6 +3212,21 @@ mod tests { assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally")); } + #[test] + fn context_menus_hold_commands_and_menu_bar_submenus() { + let tree = menu_tree(); + for entries in [CONTEXT_OBJECT, CONTEXT_GRAPHIC_FRAME, CONTEXT_OBJECT_TAIL, CONTEXT_TEXT_FRAME, CONTEXT_TEXT, CONTEXT_CANVAS] { + for path in entries.iter().filter_map(|e| e.strip_prefix("menu:")) { + assert!(menu_bar_submenu(&tree, path).is_some(), "no menu bar submenu {path}"); + } + let mut all = Vec::new(); + walk(&context_parse(entries), &mut all); + for (label, id, _) in &all { + assert!(ui_label(id).is_some() || designcraft_engine::find_command(id).is_some(), "context entry {label}: unknown command {id}"); + } + } + } + #[test] fn custom_workspaces_save_apply_and_delete() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());