mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 04:36:47 -06:00
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:
@@ -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()));
|
||||
|
||||
@@ -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", "运行"]),
|
||||
|
||||
@@ -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", "تشغيل"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", "実行"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user