UI: a context menu on the canvas for objects, text and the page

Right-clicking the canvas opens a menu built from the menu bar's rows
(labels, shortcuts, check marks, greyed entries, translations):

- On an object: the object is selected first unless it already is, as
  the Selection tool would. Undo/Redo, Cut/Copy/Paste/Paste Into/Paste
  in Place, Transform (rotate 90° both ways, 180°, flip), Arrange,
  Select, Group/Ungroup, Lock/Unlock All, Hide/Show All, Fitting for
  placed graphics, Content, Effects, and Fill with Placeholder Text and
  Text Frame Options for text frames.
- While typing: a click in a text frame places the caret there, except
  inside the selected text, which stays selected. Cut/Copy/Paste/Paste
  without Formatting, the Insert Special Character, White Space and
  Break Character submenus, Fill with Placeholder Text, Change Case,
  Hidden Characters, Story Editor, Select All.
- On the page or pasteboard: Undo/Redo, Paste, Paste in Place, zoom,
  Rulers, Grids & Guides, Select All.

Tools only see the primary button, so a right-click or right-drag
starts no gesture. While the menu is open, Escape and the click that
closes it go to the menu, not to the tools or shortcuts. Each split-view
pane has its own menu.
This commit is contained in:
aiongg
2026-10-10 19:11:39 +00:00
parent a127c6d5c3
commit 1194119c3e
8 changed files with 400 additions and 19 deletions
+210 -3
View File
@@ -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::<MenuRequest>(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<egui_kittest::Node<'h>> {
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<u64> {
h.state().session.doc().unwrap().doc.spreads[0].items.iter().map(|i| i.id.0).collect()
}
fn selected(h: &Harness<'_, DesignApp>) -> Vec<u64> {
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()));
+1
View File
@@ -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", "运行"]),
+1
View File
@@ -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", "تشغيل"),
+1
View File
@@ -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"),
+1
View File
@@ -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", "実行"),
+1
View File
@@ -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"),
+1
View File
@@ -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", "Запустити"),
+184 -16
View File
@@ -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<Item> {
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<Item>)], path: &str) -> Option<Item> {
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());