//! Menu bar, keyboard shortcuts and the UI-only commands. // // Ported from xfig src/w_cmdpanel.c (menu entries, order and dividers), // app-defaults/Fig (`Fig*accelerators`) and src/w_modepanel.c (mode keys) // (xfig licence, see LICENSE-xfig). #[cfg(not(target_arch = "wasm32"))] use crate::spell_dialog; use crate::{dialogs, reload, App, Pending}; use rustfig_engine::{cmd, Key, ToolEvent}; use serde_json::{json, Value}; /// A command implemented by the UI (dialogs, quitting). pub struct UiCommand { pub id: &'static str, pub label: &'static str, pub shortcut: &'static str, pub params: &'static str, } pub const UI_COMMANDS: &[UiCommand] = &[ UiCommand { id: "app.redraw", label: "Redraw", shortcut: "Ctrl+Shift+L", params: "{}", }, UiCommand { id: "app.colors", label: "Colours…", shortcut: "", params: "{\"color\"?: colour number} — the colour picker and editor (hue/saturation square, RGB/HSV, hex and X11 names, recent and user colours)", }, UiCommand { id: "app.styles", label: "Manage Styles…", shortcut: "", params: "{} — the Styles window: xfig's style manager: families of named attribute sets (Family and Style lists with Add/Delete, Set applies to the indicator; see style.*)", }, UiCommand { id: "app.character_map", label: "Character map…", shortcut: "Ctrl+Shift+M", params: "{} — the Character Map window (E5): the characters of the text font (Latin, Greek, arrows, maths; the Symbol and ZapfDingbats tables for those fonts); clicking one inserts it at the caret of the text being typed (script: text.insert_char)", }, UiCommand { id: "app.keys", label: "Keys…", shortcut: "", params: "{} — the shortcut sheet (E8): every command with its keys in the standard and in xfig's scheme, per system, searchable", }, UiCommand { id: "help.command_search", label: "Command search…", shortcut: "Ctrl+Shift+K", params: "{} — W18: a small window to find any command or menu entry by label, id or menu path; shows its keys, greys out the ones not available now, Enter runs it (Ctrl+K is xfig's Enter point)", }, UiCommand { id: "app.mouse_sheet", label: "Mouse buttons…", shortcut: "", params: "{} — W18: the mouse cheat sheet: what the left, middle and right buttons do in every mode (from the mouse-function indicator) and how to press them without a three-button mouse", }, UiCommand { id: "app.whats_new", label: "What's new…", shortcut: "", params: "{} — W18: what changed in this version (shown once after an update)", }, UiCommand { id: "app.versions", label: "Saved versions…", shortcut: "", params: "{} — the older saved versions of the current file kept in the recovery folder (W10-14), each restorable; script: file.versions, file.restore_version", }, UiCommand { id: "app.global_settings", label: "Global settings…", shortcut: "", params: "{}", }, UiCommand { id: "app.toggle_xfig_keys", label: "xfig keyboard shortcuts", shortcut: "", params: "{\"on\"?: bool} — xfig's Meta keys (Alt, macOS Option) and Ctrl mode keys", }, UiCommand { id: "app.unit_panel", label: "Unit/Scale settings…", shortcut: "", params: "{}", }, UiCommand { id: "app.new", label: "New", shortcut: "Ctrl+N", params: "{}", }, UiCommand { id: "app.open", label: "Open…", shortcut: "Ctrl+O", params: "{\"path\"?: string}", }, UiCommand { id: "app.merge", label: "Merge…", shortcut: "", params: "{\"path\"?: string}", }, UiCommand { id: "app.open_panel", label: "Open with preview…", shortcut: "", params: "{} — xfig's Open panel (W10-12): the files of a folder under a filename mask, the selected figure's preview with size, number of objects and comments; Open runs app.open (script: file.list_dir, file.preview)", }, UiCommand { id: "app.merge_panel", label: "Merge with preview…", shortcut: "", params: "{} — xfig's Merge panel (W10-12): as Open with preview, plus the Figure Offset X and Y with their units; Merge runs file.merge with the offset", }, UiCommand { id: "app.save", label: "Save", shortcut: "Ctrl+S", params: "{\"part\"?: bool} — with a compound open, asks (xfig's Save All / Save Part) unless part is given", }, UiCommand { id: "app.save_as", label: "Save As…", shortcut: "Ctrl+Shift+S", params: "{\"path\"?: string, \"part\"?: bool} — with a compound open, asks unless path or part is given", }, UiCommand { id: "app.export", label: "Export…", shortcut: "Ctrl+E", params: "{}", }, UiCommand { id: "app.print", label: "Print…", shortcut: "Ctrl+P", params: "{} — the Print panel (page setup, preview, printer, batch); print.run and print.quick print without it", }, UiCommand { id: "app.export_now", label: "Export with current settings", shortcut: "", params: "{} — xfig's ExportFile: next to the figure, the export panel's language and settings", }, UiCommand { id: "app.cancel_export", label: "Cancel the running export", shortcut: "", params: "{} — the Export panel's background export (native); its result is discarded", }, UiCommand { id: "app.show_export_folder", label: "Show the exported file in its folder", shortcut: "", params: "{} — opens the folder of the last file the Export panel wrote (native)", }, UiCommand { id: "app.quit", label: "Quit", shortcut: "Ctrl+Q", params: "{}", }, UiCommand { id: "app.search", label: "Search/Replace…", shortcut: "Ctrl+F", params: "{}", }, UiCommand { id: "app.spell_check", label: "Spell Check…", shortcut: "", params: "{} — checks the texts with the spell checker (preference spellcheckcommand) and opens the Misspelled words window; native only", }, UiCommand { id: "app.document_settings", label: "Document Settings…", shortcut: "", params: "{}", }, UiCommand { id: "app.toggle_depths", label: "Depth Manager", shortcut: "", params: "{}", }, UiCommand { id: "app.button_style", label: "Button style", shortcut: "", params: "{\"style\"?: \"3d\"|\"flat\"} — bevelled or flat buttons; toggles when omitted", }, UiCommand { id: "app.theme", label: "Theme", shortcut: "", params: "{\"theme\": \"light\"|\"dark\"|\"system\"} — colour theme (a choice in Global settings; persists; the paper stays white)", }, UiCommand { id: "app.ui_scale", label: "UI scale", shortcut: "", params: "{\"percent\": 75..200} — size of the interface (a choice in Global settings; persists)", }, UiCommand { id: "app.toggle_balloons", label: "Show info balloons", shortcut: "", params: "{} — xfig's tooltips (Alt+Y, macOS Option+Y)", }, UiCommand { id: "app.digitize", label: "Digitize...", shortcut: "", params: "{} - xfig's Digitize (Alt+Z): save the points of the selected polylines and splines as numbered files (file.digitize)", }, UiCommand { id: "app.toggle_autorefresh", label: "Autorefresh mode", shortcut: "", params: "{\"on\"?: bool} - reload the figure when its file changes on disk (asks first with unsaved edits); native only", }, UiCommand { id: "app.new_window", label: "New window...", shortcut: "", params: "{\"path\"?: string} - xfig's New xfig: start a second RustFig (with the file); native only", }, UiCommand { id: "app.keyboard_entry", label: "Enter point…", shortcut: "Ctrl+K", params: "{} — xfig's keyboard panel: type the next point's coordinates (opens the window; tool.point does the work)", }, UiCommand { id: "app.commands", label: "Command List", shortcut: "", params: "{}", }, UiCommand { id: "help.open", label: "Manual", shortcut: "", params: "{\"topic\"?: string} — open the bundled manual, at a topic (file name such as \"chop.md\", or its title) or at the first page", }, UiCommand { id: "app.install_tex_gyre", label: "Install TeX Gyre fonts…", shortcut: "", params: "{} — download CTAN's TeX Gyre fonts (checked by SHA-256) into RustFig's user data folder, in the background; native only", }, UiCommand { id: "app.about", label: "About RustFig", shortcut: "", params: "{}", }, UiCommand { id: "app.capture", label: "Capture Screen…", shortcut: "", params: "{} — D11, xfig's Screen Capture: the window steps aside, the system's selector picks a window or an area of the screen (Windows: the whole screen), then Save asks for the PNG file name (default
_.png) and the image becomes a picture object; not in the browser", }, UiCommand { id: "app.import_picture", label: "Import Picture…", shortcut: "", params: "{\"path\"?: string}", }, ]; /// Whether a save asks "all or part" first: with a compound open, unless /// the caller (a script) already said which with `part` or named the file. fn asks_scope(app: &App, p: &Value) -> bool { app.engine.open_compounds() > 0 && p.get("part").is_none() && p.get("path").is_none() } pub fn find_ui(id: &str) -> Option<&'static UiCommand> { UI_COMMANDS.iter().find(|c| c.id == id) } /// Run a UI-only command. /// Set the theme setting (E9): "system", "light" or "dark". pub fn set_theme(app: &mut App, theme: &str) -> Result<(), String> { let pref = match theme { "light" => egui::ThemePreference::Light, "dark" => egui::ThemePreference::Dark, "system" => egui::ThemePreference::System, _ => return Err("theme must be light, dark or system".into()), }; app.theme = theme.to_string(); app.theme_request = Some(pref); Ok(()) } pub fn run_ui(app: &mut App, id: &str, p: &Value) -> Result { let path = p.get("path").and_then(Value::as_str).map(str::to_string); match id { "app.new" => app.guard_unsaved(Pending::New), // The browser asks for the file first, the unsaved changes after (web_io). "app.open" if path.is_none() && app.file_dialogs.is_web() => { app.web_pick(crate::web_io::WebPurpose::Open, "Figures", dialogs::FIGURE_EXTS); } "app.open" => app.guard_unsaved(Pending::Open(path)), // The browser's Merge: the picked file's bytes go to file.merge_data. "app.merge" if path.is_none() && app.file_dialogs.is_web() => { app.web_pick(crate::web_io::WebPurpose::Merge, "Figures", dialogs::FIGURE_EXTS); } "app.merge" => { if let Some(p) = path.or_else(|| app.file_dialogs.pick_file("Figures", dialogs::FIGURE_EXTS)) { return Ok(dialogs::open_or_merge(app, &p, true, json!({})).unwrap_or_else(|| json!({}))); } } "app.open_panel" | "app.merge_panel" => { if app.file_dialogs.is_web() { return Err("The file panel needs folders; the browser's Open and Merge pick a file".into()); } app.file_panel.show(id == "app.merge_panel"); } // The browser saves by download (C2). "app.save" | "app.save_as" if path.is_none() && app.file_dialogs.is_web() => crate::web_io::save(app), "app.save" | "app.save_as" if asks_scope(app, p) => { // xfig asks before saving inside an open compound. app.dialogs.save_scope = Some(if id == "app.save" { "app.save" } else { "app.save_as" }); } "app.save" => { let part = p.get("part").and_then(Value::as_bool).unwrap_or(false); if app.engine.path().is_some() { let r = app .engine .execute("file.save", &json!({ "part": part })) .map_err(|e| e.message)?; let status = app.engine.status().to_string(); if status.is_empty() { app.info("Saved"); } else { app.info(status); app.engine.set_status(""); } return Ok(r); } return run_ui(app, "app.save_as", p); } "app.save_as" => { let part = p.get("part").and_then(Value::as_bool).unwrap_or(false); if let Some(p) = path.or_else(|| app.file_dialogs.save_file("figure.fig", "Figures", &["fig", "rustfig"])) { let r = app .engine .execute("file.save", &json!({ "path": p, "part": part })) .map_err(|e| e.message)?; let status = app.engine.status().to_string(); if status.is_empty() { app.info(format!("Saved {p}")); } else { app.info(status); app.engine.set_status(""); } return Ok(r); } } "app.export" => app.dialogs.export.open = true, "app.print" => app.dialogs.print.open = true, "app.export_now" => dialogs::export_now(app), "app.cancel_export" => { #[cfg(not(target_arch = "wasm32"))] crate::export_bg::cancel(app); } "app.show_export_folder" => { #[cfg(not(target_arch = "wasm32"))] { let path = app.last_export.clone().ok_or("No export to show")?; crate::export_bg::show_in_folder(&path)?; } #[cfg(target_arch = "wasm32")] return Err("Show in folder is not available on the web".into()); } "app.quit" => app.guard_unsaved(Pending::Quit), "app.search" => app.dialogs.search.open = true, "app.spell_check" => { #[cfg(target_arch = "wasm32")] return Err("Spell check is not available on the web".into()); #[cfg(not(target_arch = "wasm32"))] spell_dialog::check(app); } "app.document_settings" => app.dialogs.settings_open = true, "app.unit_panel" => app.unit_panel_open = true, "app.keyboard_entry" => app.point_entry.open(), "app.global_settings" => app.dialogs.global_open = true, "app.versions" => app.dialogs.versions_open = true, "app.styles" => app.styles.open = true, "app.character_map" => app.charmap.open = true, "app.keys" => app.keys_sheet.open = true, "help.command_search" => app.command_search.show(), "app.mouse_sheet" => app.mouse_sheet.show(), "app.whats_new" => app.whats_new_open = true, "app.colors" => { let current = p .get("color") .and_then(Value::as_i64) .and_then(|c| i32::try_from(c).ok()) .unwrap_or(app.engine.indicator.pen_color); crate::color_dialog::open(app, current); } "app.toggle_xfig_keys" => { app.xfig_keys = p.get("on").and_then(Value::as_bool).unwrap_or(!app.xfig_keys); } "app.toggle_depths" => app.show_depths = !app.show_depths, "app.toggle_balloons" => app.show_balloons = !app.show_balloons, "app.digitize" => app.digitize.open = true, "app.toggle_autorefresh" => { let on = p.get("on").and_then(Value::as_bool).unwrap_or(!app.reload.on); app.reload.set(on); app.info(if on { "Autorefresh mode ON" } else { "Autorefresh mode OFF" }); } "app.new_window" => { let file = p.get("path").and_then(Value::as_str); reload::new_window(file)?; } "app.button_style" => { app.button_3d = match p.get("style").and_then(Value::as_str) { Some("3d") => true, Some("flat") => false, Some(other) => return Err(format!("unknown button style {other:?} (3d or flat)")), None => !app.button_3d, }; } "app.theme" => { set_theme(app, p.get("theme").and_then(Value::as_str).unwrap_or(""))?; } "app.ui_scale" => { // Percent (75 to 200) or a factor (0.75 to 2.0). let v = p .get("percent") .and_then(Value::as_f64) .map(|x| x / 100.0) .or_else(|| p.get("scale").and_then(Value::as_f64)) .ok_or("ui_scale needs percent (75 to 200) or scale (0.75 to 2.0)")?; if !v.is_finite() || !(0.75..=2.0).contains(&v) { return Err("the UI scale is 75 % to 200 %".into()); } app.ui_scale = crate::settings::clamp_scale(v as f32); } "app.redraw" => app.canvas.invalidate(), "app.commands" => app.dialogs.commands_open = true, "app.about" => app.dialogs.about_open = true, "app.install_tex_gyre" => { #[cfg(not(target_arch = "wasm32"))] crate::tex_gyre::start(app)?; #[cfg(target_arch = "wasm32")] return Err("Installing fonts is not available on the web".into()); } "help.open" => app.dialogs.help.show(p.get("topic").and_then(Value::as_str))?, "app.capture" => { if !rustfig_os::capture::available() { return Err(rustfig_os::capture::unavailable_reason().to_string()); } app.capture.requested = true; } "app.import_picture" if path.is_none() && app.file_dialogs.is_web() => { app.web_pick( crate::web_io::WebPurpose::PictureImport, "Pictures", dialogs::PICTURE_EXTS, ); } "app.import_picture" => { let file = path.or_else(|| app.file_dialogs.pick_file("Pictures", dialogs::PICTURE_EXTS)); if let Some(f) = file { let c = app.engine.view.visible().center(); return app .engine .execute("shape.picture", &json!({"path": f, "x": c.x, "y": c.y})) .map_err(|e| e.message); } } _ => return Err(format!("unknown UI command {id:?}")), } Ok(json!({})) } /// An entry of xfig's menus (`file_menu_items` … in `w_cmdpanel.c`). #[derive(Clone, Copy)] pub enum XfigItem { /// xfig's label and the RustFig command it runs. Cmd(&'static str, &'static str), /// An entry RustFig doesn't have yet: label, xfig's accelerator, plan task. Missing(&'static str, &'static str, &'static str), /// A RustFig command xfig has no entry for, labelled as the command is. Own(&'static str), /// A submenu. Sub(&'static str, &'static [XfigItem]), Sep, } /// xfig's menus, word for word and in order; RustFig's extra entries /// (`Own`) follow xfig's own, and the Object menu is RustFig's. The Snap /// menu is [`snap_menu`] and sits before Help; preferences are under /// Edit › Global settings. pub const XFIG_MENUS: &[(&str, &[XfigItem])] = { use XfigItem::{Cmd, Own, Sep}; &[ ( "File", &[ Cmd("New", "app.new"), Cmd("Open...", "app.open"), Cmd("Merge...", "app.merge"), Own("app.open_panel"), Own("app.merge_panel"), Cmd("Digitize...", "app.digitize"), Cmd("Save", "app.save"), Cmd("Save As...", "app.save_as"), Cmd("Export...", "app.export"), Own("app.import_picture"), Own("app.capture"), Own("app.new_window"), Own("app.versions"), Cmd("Print...", "app.print"), Cmd("Exit", "app.quit"), ], ), ( "Edit", &[ Cmd("Undo", "edit.undo"), Cmd("Paste Objects", "edit.paste"), Cmd("Paste Text", "text.paste"), Cmd("Search/Replace...", "app.search"), #[cfg(not(target_arch = "wasm32"))] Cmd("Spell Check...", "app.spell_check"), #[cfg(target_arch = "wasm32")] XfigItem::Missing("Spell Check...", "Alt+K", "web"), Cmd("Delete All", "edit.delete_all"), Sep, Cmd("Global settings...", "app.global_settings"), Cmd("Colours...", "app.colors"), Cmd("Set units...", "app.unit_panel"), Own("app.document_settings"), Own("app.keyboard_entry"), Sep, Own("edit.redo"), Own("edit.copy_picture_pick"), Own("plot.panel"), Own("plot.edit"), ], ), ( "View", &[ Cmd("Manage Styles...", "app.styles"), Cmd("Redraw", "app.redraw"), Cmd("Portrait/Landscape", "doc.toggle_orientation"), Cmd("Zoom In", "view.zoom_in"), Cmd("Zoom Out", "view.zoom_out"), Cmd("Zoom to Fit canvas", "view.zoom_fit"), Cmd("Unzoom", "view.zoom_actual"), Cmd("Pan to origin", "view.pan_origin"), Cmd("Character map...", "app.character_map"), Sep, Cmd("Show page borders", "view.toggle_page_border"), Cmd("Typeset LaTeX text", "view.toggle_latex_preview"), Cmd("Show depth manager", "app.toggle_depths"), Cmd("Show info balloons", "app.toggle_balloons"), Cmd("Show line lengths", "view.toggle_lengths"), Cmd("Show vertex numbers", "view.toggle_vertex_numbers"), Cmd("Show crosshair", "view.toggle_crosshair"), Cmd("Autorefresh mode", "app.toggle_autorefresh"), Own("view.toggle_grid"), Own("depth.show_all"), ], ), ( "Help", &[ Cmd("RustFig Manual...", "help.open"), Cmd("RustFig Reference (commands)...", "app.commands"), Cmd("Keys...", "app.keys"), Own("help.command_search"), Own("app.mouse_sheet"), Own("app.whats_new"), Cmd("Install TeX Gyre fonts...", "app.install_tex_gyre"), Cmd("About RustFig...", "app.about"), ], ), ] }; /// The letters that pick an entry while one of xfig's menus is open /// (`Fig*commands*File*translations` … in `app-defaults/Fig`): menu, letter, /// and the entry's command or, for an entry RustFig lacks, its label. pub const XFIG_MENU_LETTERS: &[(&str, char, &str)] = &[ ("File", 'n', "app.new"), ("File", 'o', "app.open"), ("File", 'm', "app.merge"), ("File", 'd', "app.digitize"), ("File", 's', "app.save"), ("File", 'a', "app.save_as"), ("File", 'e', "app.export"), ("File", 'p', "app.print"), ("File", 'x', "app.quit"), ("Edit", 'u', "edit.undo"), ("Edit", 'p', "edit.paste"), ("Edit", 't', "text.paste"), ("Edit", 'f', "app.search"), #[cfg(not(target_arch = "wasm32"))] ("Edit", 's', "app.spell_check"), #[cfg(target_arch = "wasm32")] ("Edit", 's', "Spell Check..."), ("Edit", 'd', "edit.delete_all"), ("Edit", 'g', "app.global_settings"), ("Edit", 'n', "app.unit_panel"), ("View", 's', "app.styles"), ("View", 'r', "app.redraw"), ("View", 't', "doc.toggle_orientation"), ("View", 'i', "view.zoom_in"), ("View", 'o', "view.zoom_out"), ("View", 'f', "view.zoom_fit"), ("View", 'u', "view.zoom_actual"), ("View", 'p', "view.pan_origin"), ("View", 'c', "app.character_map"), ("View", 'd', "app.toggle_depths"), ("View", 'n', "app.toggle_balloons"), ("View", 'b', "view.toggle_page_border"), ("View", 'x', "view.toggle_latex_preview"), ("View", 'l', "view.toggle_lengths"), ("View", 'v', "view.toggle_vertex_numbers"), ("View", 'h', "view.toggle_crosshair"), ("View", 'a', "app.toggle_autorefresh"), ("Help", 'x', "help.open"), ("Help", 'c', "app.commands"), ("Help", 'k', "app.keys"), ("Help", 'a', "app.about"), ]; /// The menus Meta (Alt) + letter pops up (`Fig*commands*File*accelerators` …). pub(crate) const XFIG_MENU_KEYS: &[(&str, &str)] = &[ ("Alt+F", "File"), ("Alt+E", "Edit"), ("Alt+V", "View"), ("Alt+H", "Help"), ]; /// The command xfig's letter picks in the open `menu`, if RustFig has it. pub fn xfig_menu_letter(menu: &str, letter: char) -> Option<&'static str> { XFIG_MENU_LETTERS .iter() .find(|(m, l, _)| *m == menu && *l == letter) .map(|(_, _, c)| *c) .filter(|c| cmd::find(c).is_some() || find_ui(c).is_some()) } /// Recent files (xfig lists them at the end of its File menu, numbered; /// 1–9 open them while the menu is open). fn recent_files(app: &mut App, ui: &mut egui::Ui) { let files = app.engine.prefs.rustfig.recent_files.clone(); if files.is_empty() { return; } ui.separator(); for (i, f) in files.iter().enumerate() { let name = std::path::Path::new(f) .file_name() .and_then(|n| n.to_str()) .unwrap_or(f.as_str()); let digit = egui::Key::from_name(&(i + 1).to_string()); let key = digit.is_some_and(|k| ui.input_mut(|inp| inp.consume_key(egui::Modifiers::NONE, k))); if ui.button(format!("{} {name}", i + 1)).on_hover_text(f).clicked() || key { ui.close(); app.run("app.open", json!({ "path": f })); } } } fn xfig_menu(app: &mut App, ui: &mut egui::Ui, menu: &str, items: &[XfigItem]) { // A letter picks its entry, as in xfig. for (m, letter, _) in XFIG_MENU_LETTERS.iter().filter(|(m, _, _)| *m == menu) { let Some(key) = egui::Key::from_name(&letter.to_ascii_uppercase().to_string()) else { continue; }; if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key)) { if let Some(id) = xfig_menu_letter(m, *letter) { if app.enabled(id) { ui.close(); app.run(id, json!({})); return; } } } } xfig_items(app, ui, items); if menu == "File" { recent_files(app, ui); } } /// Commands that do nothing in the browser (desktop windows, folders, /// screen capture, quitting): the web menus leave them out. pub const DESKTOP_ONLY: &[&str] = &[ "app.open_panel", "app.merge_panel", "app.digitize", "app.new_window", "app.versions", "app.capture", "app.quit", ]; /// Whether a menu entry shows: every entry on the desktop, all but /// [`DESKTOP_ONLY`] on the web. pub fn entry_visible(item: &XfigItem, web: bool) -> bool { match item { XfigItem::Cmd(_, id) | XfigItem::Own(id) => !(web && DESKTOP_ONLY.contains(id)), _ => true, } } fn xfig_items(app: &mut App, ui: &mut egui::Ui, items: &[XfigItem]) { for item in items { if !entry_visible(item, cfg!(target_arch = "wasm32")) { continue; } match *item { XfigItem::Sep => { ui.separator(); } XfigItem::Sub(name, sub) => { ui.menu_button(name, |ui| xfig_items(app, ui, sub)); } XfigItem::Own(id) => menu_entry(app, ui, label_of(id).0, id), XfigItem::Cmd(label, id) => menu_entry(app, ui, label, id), XfigItem::Missing(label, accel, task) => { let mut b = egui::Button::new(label); if !accel.is_empty() { b = b.shortcut_text(xfig_key_label(accel)); } let why = if task == "web" { "Not available on the web".to_string() } else if task == "—" { "Not available in RustFig".to_string() } else { format!("Not available yet (plan task {task})") }; ui.add_enabled(false, b).on_disabled_hover_text(why); } } } } fn menu_entry(app: &mut App, ui: &mut egui::Ui, label: &str, id: &str) { let text = match checked(app, id) { Some(true) => format!("✔ {label}"), Some(false) => format!(" {label}"), None => label.to_string(), }; let shortcut = entry_keys(id, app.xfig_keys, crate::platform::is_mac()); let mut b = egui::Button::new(text); if !shortcut.is_empty() { b = b.shortcut_text(shortcut); } if ui.add_enabled(app.enabled(id), b).clicked() { ui.close(); app.run(id, json!({})); } } /// Menu labels that differ from the command labels, using xfig's wording. const MENU_LABELS: &[(&str, &str)] = &[ ("app.quit", "Exit"), ("edit.paste", "Paste Objects"), ("view.zoom_fit", "Zoom to Fit canvas"), ("view.toggle_page_border", "Show page borders"), ("app.toggle_depths", "Show depth manager"), ("view.toggle_grid", "Show grid"), ("view.toggle_lengths", "Show line lengths"), ("view.toggle_vertex_numbers", "Show vertex numbers"), ("view.toggle_crosshair", "Show crosshair"), ("app.document_settings", "Units and Page…"), ]; /// Check mark state of toggle entries (xfig marks them with an asterisk). fn checked(app: &App, id: &str) -> Option { match id { "view.toggle_page_border" => Some(app.engine.show_page_border), "view.toggle_latex_preview" => Some(app.engine.prefs.rustfig.latex_preview.unwrap_or(true)), "app.toggle_depths" => Some(app.show_depths), "app.toggle_balloons" => Some(app.show_balloons), "app.toggle_autorefresh" => Some(app.reload.on), "view.toggle_grid" => Some(app.engine.indicator.grid.is_some()), "view.toggle_lengths" => Some(app.engine.show_lengths), "view.toggle_vertex_numbers" => Some(app.engine.show_vertex_numbers()), "view.toggle_crosshair" => Some(app.engine.crosshair), "app.toggle_xfig_keys" => Some(app.xfig_keys), _ => None, } } fn label_of(id: &str) -> (&'static str, &'static str) { if let Some((_, l)) = MENU_LABELS.iter().find(|(i, _)| *i == id) { let shortcut = cmd::find(id) .map(|c| c.shortcut) .or_else(|| find_ui(id).map(|c| c.shortcut)) .unwrap_or(""); return (l, shortcut); } if let Some(c) = cmd::find(id) { return (c.label, c.shortcut); } if let Some(c) = find_ui(id) { return (c.label, c.shortcut); } ("?", "") } /// One of the classic top strip's menu buttons, bevelled in the 3D style /// (sunken while its menu is open). fn bar_button( app: &mut App, ui: &mut egui::Ui, tk: &crate::theme::Tokens, name: &str, content: impl FnOnce(&mut App, &mut egui::Ui), ) -> egui::Response { let ctx = ui.ctx().clone(); let resp = crate::bevel::around( ui, tk, |r| egui::Popup::is_id_open(&ctx, egui::Popup::default_response_id(r)), |ui| { ui.scope(|ui| { crate::bevel::bare_buttons(ui, tk); ui.add(egui::Button::new(name)) }) .inner }, ); egui::Popup::menu(&resp).show(|ui| content(app, ui)); resp } /// The menu buttons alone, laid out in the current row. pub fn menu_buttons(app: &mut App, ui: &mut egui::Ui) { // xfig's menus: File, Edit, View, Snap, Help. let tk = crate::theme::Tokens::new(ui.visuals().dark_mode, app.button_3d); for (name, items) in XFIG_MENUS { if *name == "Help" { bar_button(app, ui, &tk, "Snap", snap_menu); } let resp = bar_button(app, ui, &tk, name, |app, ui| xfig_menu(app, ui, name, items)); if app.open_menu == Some(*name) { app.open_menu = None; egui::Popup::close_all(ui.ctx()); egui::Popup::open_id(ui.ctx(), egui::Popup::default_response_id(&resp)); } } } /// xfig's Snap menu (`snap_menu_items` in `w_cmdpanel.c`): Hold and /// Release, then the snap modes for the next clicked point. Point /// positioning and the grid are on the attribute panel, as in xfig. pub fn snap_menu(app: &mut App, ui: &mut egui::Ui) { use rustfig_engine::ObjectSnap as S; let held = app.engine.snap_held(); let hold_label = if held { "\u{2713} Hold" } else { "Hold" }; if ui .add(egui::Button::new(hold_label).selected(held)) .on_hover_text("Keep the snap mode after each point until Release") .clicked() { app.run("snap.hold", json!({})); ui.close(); } if ui .add_enabled(held, egui::Button::new("Release")) .on_hover_text("Stop holding and disarm the snap mode") .clicked() { app.run("snap.release", json!({})); ui.close(); } let current = app.engine.object_snap(); for group in [ &[S::Endpoint, S::Midpoint, S::Nearest, S::Focus][..], &[S::Normal, S::Tangent, S::Intersect, S::Diameter][..], ] { ui.separator(); for m in group { let label = if *m == S::Intersect { "Intersection" } else { m.label() }; if ui.add(egui::Button::new(label).selected(current == Some(*m))).clicked() { app.run("snap.mode", json!({ "mode": m.name() })); ui.close(); } } } } /// The one key a menu entry shows (both keys work; Help > Keys lists both): /// xfig's accelerator while xfig's keys are on, else the platform's /// shortcut where xfig's Ctrl keys leave it free (the user's choice, W20). pub(crate) fn entry_keys(id: &str, xfig_keys: bool, mac: bool) -> String { // Paste Text: xfig's F18/F20 (Sun Paste keys) are gone from keyboards; // while typing, the platform's paste key inserts the clipboard's text. if id == "text.paste" { return if mac { mac_shortcut("Ctrl+V") } else { "Ctrl+V".to_string() }; } let (platform, xfig) = entry_key_pair(id, mac); let platform_shadowed = xfig_keys && shadowed_by_xfig(label_of(id).1, mac); // Mode letters are always on; xfig's other keys follow its switch. if !xfig.is_empty() && (xfig_keys || is_mode_letter(label_of(id).1)) { return xfig; } if platform_shadowed { return String::new(); } platform } /// A plain letter shortcut (`C`, `Shift+E`): xfig's mode key, not a system key. fn is_mode_letter(spec: &str) -> bool { !spec.contains("Ctrl") && !spec.contains("Alt") && spec .rsplit('+') .next() .is_some_and(|k| k.len() == 1 && k.chars().all(|c| c.is_ascii_alphabetic())) } /// (platform's shortcut, xfig's accelerator) of a command as written for the /// system; empty where there is none. pub(crate) fn entry_key_pair(id: &str, mac: bool) -> (String, String) { let platform = label_of(id).1; let platform = if platform.is_empty() { String::new() } else { shortcut_text(platform, mac) }; let mut xfig = xfig_accelerator(id).map(|k| key_label(k, mac)).unwrap_or_default(); // xfig's mode letters belong to xfig's column; the system column shows a // key only where it differs from xfig's. let platform = if is_mode_letter(label_of(id).1) { if xfig.is_empty() { xfig = platform; } String::new() } else { platform }; let platform = if platform == xfig { String::new() } else { platform }; (platform, xfig) } /// Whether one of xfig's Ctrl keys takes the platform shortcut `s` (Linux /// and Windows, where the platform's Ctrl is xfig's Ctrl too; xfig's keys are /// handled first). fn shadowed_by_xfig(s: &str, mac: bool) -> bool { let Some(k) = parse_shortcut(s) else { return false }; !mac && k.modifiers.command && XFIG_KEYS .iter() .filter_map(|(spec, _)| parse_xfig_key(spec)) .any(|(alt, shift, ctrl, key)| { ctrl && key == k.logical_key && shift == k.modifiers.shift && alt == k.modifiers.alt }) } /// Thin space between the modifier symbols and the key on macOS. pub(crate) const THIN: char = '\u{2009}'; /// Modifiers and key as the system writes them. macOS: the symbols /// ⌃ ⌥ ⇧ ⌘ (in Apple's order), a thin space, the key ("⇧⌘ Z"); Windows and /// Linux: "Ctrl+Alt+Shift+Key". `ctrl` is the Control key itself, `cmd` the /// platform's command key (Ctrl on Windows and Linux). fn write_keys(mac: bool, ctrl: bool, alt: bool, shift: bool, cmd: bool, key: &str) -> String { if mac { let mut out = String::new(); for (on, sym) in [(ctrl, '⌃'), (alt, '⌥'), (shift, '⇧'), (cmd, '⌘')] { if on { out.push(sym); } } if !out.is_empty() { out.push(THIN); } out + key } else { let mut parts = Vec::new(); if ctrl || cmd { parts.push("Ctrl"); } if alt { parts.push("Alt"); } if shift { parts.push("Shift"); } parts.push(key); parts.join("+") } } /// A shortcut spec ("Ctrl+Shift+Z", Ctrl = the platform's command key) as /// macOS writes it. fn mac_shortcut(s: &str) -> String { shortcut_text(s, true) } fn shortcut_text(s: &str, mac: bool) -> String { let (mut cmd, mut alt, mut shift, mut key) = (false, false, false, String::new()); for part in s.split('+') { match part { "Ctrl" => cmd = true, "Shift" => shift = true, "Alt" => alt = true, // "Ctrl++" (a plus key) splits into empty parts. "" => key = "+".to_string(), k => key = k.to_string(), } } write_keys(mac, false, alt, shift, cmd, &key) } /// Human-readable shortcut ("Ctrl" shows as ⌘ on macOS). pub fn display_shortcut(s: &str) -> String { shortcut_text(s, crate::platform::is_mac()) } /// Parse "Ctrl+Shift+Z" into an egui shortcut. pub fn parse_shortcut(s: &str) -> Option { let mut mods = egui::Modifiers::NONE; let mut key = None; for part in s.split('+') { match part { "Ctrl" => mods |= egui::Modifiers::COMMAND, "Shift" => mods |= egui::Modifiers::SHIFT, "Alt" => mods |= egui::Modifiers::ALT, "" => key = Some(egui::Key::Plus), k => { key = match k { "=" => Some(egui::Key::Equals), "-" => Some(egui::Key::Minus), "'" => Some(egui::Key::Quote), "Delete" => Some(egui::Key::Delete), "Escape" => Some(egui::Key::Escape), "Up" => Some(egui::Key::ArrowUp), "Down" => Some(egui::Key::ArrowDown), other => egui::Key::from_name(other), } } } } key.map(|k| egui::KeyboardShortcut::new(mods, k)) } /// xfig's keyboard accelerators (`app-defaults/Fig` and the mode tooltips /// in `w_modepanel.c`): Meta (Alt/Option) + letter for menu entries, and /// physical Ctrl + letter for some modes. Specs use "Alt", "Shift" and /// "PhysCtrl" (the Control key itself, also on macOS where the platform's /// shortcuts use Cmd); xfig entries RustFig has no command for yet ( /// spell check, global settings, named styles) are left out. pub const XFIG_KEYS: &[(&str, &str)] = &[ ("Alt+N", "app.new"), ("Alt+O", "app.open"), ("Alt+M", "app.merge"), ("Alt+Z", "app.digitize"), ("Alt+S", "app.save"), ("Alt+A", "app.save_as"), ("Alt+X", "app.export"), ("Shift+Alt+X", "app.export_now"), ("Alt+P", "app.print"), ("Shift+Alt+P", "print.quick"), ("Alt+Q", "app.quit"), ("Alt+U", "edit.undo"), ("Alt+T", "edit.paste"), ("Alt+I", "app.search"), ("Alt+K", "app.spell_check"), ("Alt+D", "edit.delete_all"), ("PhysCtrl+L", "app.redraw"), ("Alt+R", "app.redraw"), ("Alt+C", "doc.toggle_orientation"), ("PhysCtrl+Z", "view.zoom_fit"), ("Alt+B", "view.toggle_page_border"), ("Alt+Y", "app.toggle_balloons"), ("Alt+L", "view.toggle_lengths"), ("PhysCtrl+K", "app.keyboard_entry"), ("PhysCtrl+Y", "app.styles"), ("Alt+G", "app.global_settings"), ("Shift+U", "app.unit_panel"), ("Shift+Z", "view.zoom_in"), ("Z", "view.zoom_out"), ("PhysCtrl+S", "tool.scale"), ("PhysCtrl+C", "tool.copy"), ("PhysCtrl+A", "tool.add_point"), ("PhysCtrl+E", "tool.edit"), ("PhysCtrl+R", "tool.rotate_cw"), ("PhysCtrl+P", "tool.picture"), ("PhysCtrl+G", "tool.angle_measure"), ("PhysCtrl+N", "tool.length_measure"), ("PhysCtrl+M", "tool.area_measure"), ]; /// (alt, shift, physical ctrl, key) of an xfig key spec. fn parse_xfig_key(spec: &str) -> Option<(bool, bool, bool, egui::Key)> { let (mut alt, mut shift, mut ctrl, mut key) = (false, false, false, None); for part in spec.split('+') { match part { "Alt" => alt = true, "Shift" => shift = true, "PhysCtrl" => ctrl = true, k => key = egui::Key::from_name(k), } } key.map(|k| (alt, shift, ctrl, k)) } /// An xfig accelerator as the platform names its keys. xfig prints "Meta-N", /// "Shift-Meta-X", "Ctrl-L"; its Meta is the Alt key (Option on macOS), so /// macOS shows "⌥ N", "⇧⌥ X", "⌃ L" (symbols, see `write_keys`) and Linux and /// Windows "Alt+N", "Shift+Alt+X", "Ctrl+L". /// name; the ⌥ symbol is missing from the fallback fonts) and Linux and /// Windows "Alt+N", "Shift+Alt+X", "Ctrl+L". pub fn xfig_key_label(spec: &str) -> String { key_label(spec, crate::platform::is_mac()) } pub(crate) fn key_label_for(spec: &str, mac: bool) -> String { key_label(spec, mac) } fn key_label(spec: &str, mac: bool) -> String { if !spec.contains('+') { // A bare letter is lower case in xfig's labels: "(z)". return spec.to_ascii_lowercase(); } let (mut ctrl, mut alt, mut shift, mut key) = (false, false, false, String::new()); for part in spec.split('+') { match part { "PhysCtrl" => ctrl = true, "Alt" => alt = true, "Shift" => shift = true, k => key = k.to_string(), } } write_keys(mac, ctrl, alt, shift, false, &key) } /// The xfig accelerator shown for a command in the menus. pub fn xfig_accelerator(id: &str) -> Option<&'static str> { XFIG_KEYS.iter().find(|(_, c)| *c == id).map(|(k, _)| *k) } /// Consume one key press matching an xfig spec exactly. fn take_xfig_key(ctx: &egui::Context, spec: &str) -> bool { let Some((alt, shift, ctrl, key)) = parse_xfig_key(spec) else { return false; }; ctx.input_mut(|i| { let pos = i.events.iter().position(|e| match e { egui::Event::Key { key: k, pressed: true, modifiers: m, .. } => { *k == key && m.alt == alt && m.shift == shift && m.ctrl == ctrl && !(crate::platform::is_mac() && m.mac_cmd) } _ => false, }); match pos { Some(p) => { i.events.remove(p); true } None => false, } }) } /// xfig's compose key (`CompKeyDB`, `w_canvas.c`): Meta (Alt) pressed and /// released on its own while typing arms it; the next two characters make /// one (`rustfig_text::compose`), or are typed as they are if they make /// none. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub(crate) enum Compose { #[default] Off, /// Alt is down and nothing else was pressed yet. AltAlone, Armed, First(char), } impl Compose { fn watch_alt(&mut self, alt: bool, events: &[egui::Event]) { let other_key = events .iter() .any(|e| matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::Text(_))); *self = match (*self, alt) { (Compose::Off, true) if !other_key => Compose::AltAlone, (Compose::AltAlone, true) if other_key => Compose::Off, (Compose::AltAlone, false) => Compose::Armed, (s, _) => s, }; } } /// Typed text after the compose key: what reaches the text tool, if anything. fn compose_text(app: &mut App, text: String) -> Option { let mut out = String::new(); for c in text.chars() { app.compose = match app.compose { Compose::Armed => Compose::First(c), Compose::First(a) => { match rustfig_text::compose(a, c) { Some(x) => out.push(x), None => { out.push(a); out.push(c); } } Compose::Off } other => { out.push(c); other } }; } (!out.is_empty()).then_some(out) } /// xfig's canvas keys (`w_canvas.c`): the arrow keys pan by a ruler step /// (Shift: five), Home pans to the origin. While text is typed, Left, Right /// and Home move the caret instead. fn pan_keys(app: &mut App, ctx: &egui::Context, typing: bool) { let units = app.engine.doc().settings.units; let zoom = app.engine.view.zoom; let s = app.engine.view.px_per_fig(); // Shift first: a pattern without Shift may match Shift+key too. for shift in [true, false] { let mods = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; let step = crate::rulers::pan_step(units, zoom, shift) * s; // xfig: Left is `pan_right` (the drawing moves right), Up `pan_down`. let keys = [ (egui::Key::ArrowLeft, step, 0.0, false), (egui::Key::ArrowRight, -step, 0.0, false), (egui::Key::ArrowUp, 0.0, step, true), (egui::Key::ArrowDown, 0.0, -step, true), ]; for (key, dx, dy, while_typing) in keys { if (!typing || while_typing) && ctx.input_mut(|i| i.consume_key(mods, key)) { app.run("view.pan", json!({"dx": dx, "dy": dy})); } } } if !typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Home)) { app.run("view.pan_origin", json!({})); } } /// A key while text is typed (E10): Shift extends the selection, Alt (macOS) /// or Ctrl (Linux, Windows) jumps by words, Cmd+Left/Right is Home/End on /// macOS, Cmd/Ctrl+A selects all. fn text_key(os: crate::platform::Os, key: egui::Key, m: egui::Modifiers) -> Option { let mac = os == crate::platform::Os::Mac; let word = if mac { m.alt } else { m.ctrl }; let line = mac && m.mac_cmd; let nav = |key, shift, word| Some(ToolEvent::Nav { key, shift, word }); match key { egui::Key::Enter => Some(ToolEvent::Key(Key::Enter)), egui::Key::Escape => Some(ToolEvent::Key(Key::Escape)), egui::Key::Backspace => nav(Key::Backspace, false, word), egui::Key::Delete => nav(Key::Delete, false, word), egui::Key::ArrowLeft if line => nav(Key::Home, m.shift, false), egui::Key::ArrowRight if line => nav(Key::End, m.shift, false), egui::Key::ArrowLeft => nav(Key::Left, m.shift, word), egui::Key::ArrowRight => nav(Key::Right, m.shift, word), egui::Key::Home => nav(Key::Home, m.shift, false), egui::Key::End => nav(Key::End, m.shift, false), // xfig's Control keys (`char_handler` in `d_text.c`). On macOS the // Control key is its own (Cmd has the platform's shortcuts), so all // of them work; elsewhere Ctrl+A, Ctrl+X and Ctrl+C/V are Select all // and the clipboard, so those keep their usual meaning. egui::Key::A if mac && m.ctrl && !m.mac_cmd => nav(Key::Home, false, false), egui::Key::X if mac && m.ctrl && !m.mac_cmd => nav(Key::KillToStart, false, false), egui::Key::E if m.ctrl && !m.mac_cmd => nav(Key::End, false, false), egui::Key::B if m.ctrl && !m.mac_cmd => nav(Key::Left, false, false), egui::Key::F if m.ctrl && !m.mac_cmd => nav(Key::Right, false, false), egui::Key::D if m.ctrl && !m.mac_cmd => nav(Key::Delete, false, false), egui::Key::H if m.ctrl && !m.mac_cmd => nav(Key::Backspace, false, false), egui::Key::K if m.ctrl && !m.mac_cmd => nav(Key::KillToEnd, false, false), egui::Key::W if m.ctrl && !m.mac_cmd => nav(Key::KillSelection, false, false), // Control-^ and Control-_: start a super- or subscript. egui::Key::Num6 if m.ctrl && m.shift => nav(Key::Superscript, false, false), egui::Key::Minus if m.ctrl && m.shift => nav(Key::Subscript, false, false), egui::Key::A if m.command => nav(Key::SelectAll, false, false), _ => None, } } /// Keyboard handling: typing goes to the text tool; otherwise shortcuts run commands. pub fn handle_shortcuts(app: &mut App, ctx: &egui::Context) { // Escape closes the command search first: egui drops the field's focus // on Escape before this runs, so it would reach `tool.escape` instead. if app.command_search.open && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { app.command_search.open = false; return; } if ctx.wants_keyboard_input() { return; } dialogs::escape_closes_panels(app, ctx); if app.mode_popup.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { app.mode_popup = None; } if app.engine.is_typing() { // The character map's key works while typing too. if let Some(k) = find_ui("app.character_map").and_then(|c| parse_shortcut(c.shortcut)) { if ctx.input_mut(|i| i.consume_shortcut(&k)) { app.run("app.character_map", json!({})); } } let events = ctx.input(|i| i.events.clone()); let os = crate::platform::Os::current(ctx); let alt = ctx.input(|i| i.modifiers.alt); app.compose.watch_alt(alt, &events); for ev in events { let tev = match ev { egui::Event::Text(t) => compose_text(app, t).map(ToolEvent::Text), egui::Event::Paste(t) => Some(ToolEvent::Text(t)), // E10: the selection goes to the system clipboard. egui::Event::Copy => { if let Some(sel) = app.engine.text_selection() { ctx.copy_text(sel); } None } egui::Event::Cut => { if let Some(sel) = app.engine.text_cut() { ctx.copy_text(sel); } None } // IME: the composition is shown at the caret, the commit is // typed text. egui::Event::Ime(egui::ImeEvent::Preedit(t)) => Some(ToolEvent::Preedit(t)), egui::Event::Ime(egui::ImeEvent::Commit(t)) => compose_text(app, t).map(ToolEvent::Text), egui::Event::Ime(egui::ImeEvent::Disabled) => Some(ToolEvent::Preedit(String::new())), egui::Event::Key { key, pressed: true, modifiers, .. } => text_key(os, key, modifiers), _ => None, }; if let Some(t) = tev { if let Err(e) = app.engine.tool_event(t) { app.error(e.message); } } } // xfig pans with Up and Down even while text is typed. pan_keys(app, ctx, true); return; } app.compose = Compose::Off; pan_keys(app, ctx, false); // A library object in hand: xfig's r, l, h, v, s, S turn, flip, shrink // and enlarge it (`transform_lib_obj`), before they are mode keys. if app.engine.library_in_hand().is_some() { let keys = [ (egui::Modifiers::NONE, egui::Key::R, "r"), (egui::Modifiers::NONE, egui::Key::L, "l"), (egui::Modifiers::NONE, egui::Key::H, "h"), (egui::Modifiers::NONE, egui::Key::V, "v"), (egui::Modifiers::NONE, egui::Key::S, "s"), (egui::Modifiers::SHIFT, egui::Key::S, "S"), ]; for (m, k, c) in keys { if ctx.input_mut(|i| i.consume_key(m, k)) { if let Err(e) = app.engine.tool_event(ToolEvent::Text(c.to_string())) { app.error(e.message); } } } } // Keys a busy tool wants (finish, remove last point). Escape is the // `tool.escape` shortcut: cancel, clear, leave the mode, one per press. if app.engine.tool_busy() { let keys = [(egui::Key::Enter, Key::Enter), (egui::Key::Backspace, Key::Backspace)]; for (k, tk) in keys { if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k)) { if let Err(e) = app.engine.tool_event(ToolEvent::Key(tk)) { app.error(e.message); } } } } // Meta-F/E/V/H pop up xfig's menus; while a menu or another popup is // open, letters belong to it, not to the shortcuts (xfig's mode keys). if app.xfig_keys { for (spec, menu) in XFIG_MENU_KEYS { if take_xfig_key(ctx, spec) { app.open_menu = Some(menu); } } } if egui::Popup::is_any_open(ctx) { return; } // xfig's accelerators first: on Windows and Linux they take Ctrl+S, // Ctrl+C, … from the platform shortcuts while the xfig keymap is on. if app.xfig_keys { for (spec, id) in XFIG_KEYS { if take_xfig_key(ctx, spec) && app.enabled(id) { app.run(id, json!({})); } } } // Most specific shortcuts (more modifiers) first, so Ctrl+Shift+Z beats Ctrl+Z. let mut all: Vec<(&'static str, egui::KeyboardShortcut)> = cmd::all() .iter() .map(|c| (c.id, c.shortcut)) .chain(UI_COMMANDS.iter().map(|c| (c.id, c.shortcut))) .filter(|(_, s)| !s.is_empty()) .filter_map(|(id, s)| parse_shortcut(s).map(|k| (id, k))) .collect(); all.sort_by_key(|(_, k)| { std::cmp::Reverse(k.modifiers.command as u8 + k.modifiers.shift as u8 + k.modifiers.alt as u8) }); let mut ran = Vec::new(); for (id, k) in all { if ctx.input_mut(|i| i.consume_shortcut(&k)) && app.enabled(id) { app.run(id, json!({})); ran.push(id); } } // The platform's paste key (Cmd+V on macOS, Ctrl+V elsewhere) reaches // egui as a clipboard event, not as a key press. let clip: Vec<&'static str> = ctx.input(|i| { i.events .iter() .filter_map(|e| match e { // xfig has no selection to cut or copy; the platform's // paste key pastes the cut buffer (Edit > Paste Objects). egui::Event::Paste(_) => Some("edit.paste"), _ => None, }) .collect() }); for id in clip { if !ran.contains(&id) && app.enabled(id) { app.run(id, json!({})); ran.push(id); } } } #[cfg(test)] mod tests { use super::*; fn menu_ids(items: &[XfigItem], out: &mut Vec<&'static str>) { for item in items { match item { XfigItem::Cmd(_, id) | XfigItem::Own(id) => out.push(id), XfigItem::Sub(_, sub) => menu_ids(sub, out), XfigItem::Missing(..) | XfigItem::Sep => {} } } } #[test] fn web_menu_omits_desktop_only_entries() { let mut shown_web = Vec::new(); let mut shown_desktop = Vec::new(); for (_, items) in XFIG_MENUS { for item in items.iter() { if let XfigItem::Cmd(_, id) | XfigItem::Own(id) = item { if entry_visible(item, true) { shown_web.push(*id); } if entry_visible(item, false) { shown_desktop.push(*id); } } } } for id in DESKTOP_ONLY { assert!(!shown_web.contains(id), "{id} shown on the web"); } for id in [ "app.digitize", "app.new_window", "app.versions", "app.quit", "app.open_panel", "app.merge_panel", ] { assert!(shown_desktop.contains(&id), "{id} missing on the desktop"); } assert!(shown_web.contains(&"app.save")); } #[test] fn xfig_menus_reach_commands() { let mut ids = Vec::new(); for (_, items) in XFIG_MENUS { menu_ids(items, &mut ids); } for id in ids { assert!(cmd::find(id).is_some() || find_ui(id).is_some(), "unknown {id}"); } } /// Commands that are deliberately in no menu, with the reason. const MENU_LESS: &[(&str, &str)] = &[ // A prefix ending in "." covers every command that starts with it. ("tool.", "the mode panel's buttons and the mode keys choose tools"), // xfig has no selection (decided 2026-10-22): these work on the // given ids for scripts and MCP; the modes do the same by mouse. ( "edit.cut", "selection-based; scripting API (Delete mode's right button fills the cut buffer)", ), ( "edit.copy", "selection-based; scripting API (Copy mode's right button; Copy as Picture picks after the command)", ), ("edit.duplicate", "selection-based; scripting API (Copy mode)"), ("edit.delete", "selection-based; scripting API (Delete mode)"), ("transform.flip_", "Flip modes; scripting API"), ("arrange.", "Align mode and the edit panel's depth; scripting API"), ("object.group", "Glue mode; scripting API"), ("object.ungroup", "Break mode; scripting API"), ("object.update", "Update mode; scripting API"), ("object.measure", "the measure modes; scripting API"), ("object.convert", "Convert mode; scripting API"), ("object.toggle_", "Open/close and Arrow modes; scripting API"), ("style.", "View > Manage Styles... (app.styles) is their window"), ("text.", "View > Character map... (app.character_map) is their window"), ( "file.", "the File menu's app.* entries wrap them (dialogs, unsaved-changes guard)", ), ("shape.", "scripting API; the drawing tools make shapes"), ("point.", "scripting API; the point tools edit vertices"), ("compound.", "the Open compound tool and the Close Compound popup"), ("prefs.", "scripting API; Global settings and the resources window"), ("indicator.", "scripting API; the attribute panel sets them"), ("color.", "scripting API; the colour dialogs"), ( "doc.", "scripting API; Units and Page, Set units and the document dialogs", ), ("depth.", "scripting API; the depth manager"), ( "view.", "scripting API (zoom, pan, grid and snap by value); the View menu and the rulers have the rest", ), ("app.toggle_xfig_keys", "a checkbox in Global settings"), ("app.button_style", "a choice in Global settings"), ("app.theme", "a choice in Global settings"), ("app.ui_scale", "a choice in Global settings"), ("print.", "the Print panel's buttons; Shift-Meta-P is print.quick"), ("app.export_now", "the Export panel's Export button and the E key"), ("app.cancel_export", "the Cancel button of the export progress window"), ("app.show_export_folder", "the Show in folder button after an export"), ("edit.select", "scripting API (xfig has no selection)"), ("edit.search_replace", "scripting API; Search/Replace... runs it"), ("text.spell_check", "scripting API; Spell Check... runs it"), ("edit.paste_text", "the middle button of the Text tool"), ( "edit.copy_array", "the middle button while a copy is placed (Num X / Num Y Copies)", ), ("arrange.align_canvas", "the middle button of the Align tool"), ( "object.recompute_dimension", "scripting API; runs by itself on scaling and closing", ), ("object.add", "scripting API"), ( "object.set_", "scripting API; the Edit panel and the attribute panel set them", ), ("object.get", "scripting API"), ("object.list", "scripting API"), ("object.join", "the Join/Split tool"), ("object.split", "the Join/Split tool"), ("object.chop", "the Chop tool"), ("object.trim", "the Trim tool's left button"), ("object.split_at", "the Trim tool's middle button"), ("object.extend", "the Trim tool's right button"), ( "object.tangent_line", "the Tangent tool (left button; middle button for the normal)", ), ( "object.measure_angle", "the Measure angle tool (three points, or a corner or arc)", ), ("object.measure_length", "the Measure length tool"), ("object.measure_area", "the Measure area tool"), ("object.measure_reset", "the measure tools' right button"), ("object.cut_all", "scripting API (cut the selection against itself)"), ("object.update_region", "the Update tool's right button"), ("object.reverse_arrows", "the Arrow tool's right button"), ("object.break_all", "the Break tool's right button"), ("transform.move", "scripting API; the Move tools"), ( "transform.nudge", "scripting API (a small move step); the arrow keys pan the canvas, as xfig", ), ("transform.scale", "scripting API; the Scale tool"), ("transform.rotate", "scripting API; the Rotate tools"), ( "arrange.set_depth", "scripting API; the Depth box of the attribute panel", ), ("arrange.align_to", "the Align tool's right button"), ( "spline.shape_factor", "Ctrl+Shift+click on a spline point (Cmd+Shift on macOS) in the editing modes", ), ("picture.", "the picture edit panel's buttons; scripting API"), ("plot.get", "scripting API; the Plot dialog loads a plot with it"), ( "plot.add", "scripting API; the Plot dialog and the Plot mode place plots with it", ), ("plot.update", "scripting API; the Plot dialog redraws a plot with it"), ( "library.", "the library mode button (Shift+L) and the library panel; scripting API", ), ( "export.", "the Export panel's rows and Fit to Page button; scripting API", ), ]; /// Every command is in a menu or listed above with a reason. #[test] fn every_command_is_reachable_from_a_menu() { let mut ids = Vec::new(); for (_, items) in XFIG_MENUS { menu_ids(items, &mut ids); } // The Snap menu is built in code. let mut missing = Vec::new(); let all = cmd::all().iter().map(|c| c.id).chain(UI_COMMANDS.iter().map(|c| c.id)); for id in all { let exempt = MENU_LESS.iter().any(|(p, _)| id.starts_with(p)); if !exempt && !ids.contains(&id) && !id.starts_with("snap.") { missing.push(id); } } assert!(missing.is_empty(), "in no menu: {missing:#?}"); // The exemptions are not stale. for (p, _) in MENU_LESS { assert!( cmd::all() .iter() .map(|c| c.id) .chain(UI_COMMANDS.iter().map(|c| c.id)) .any(|i| i.starts_with(p)), "{p}" ); } } #[test] fn xfig_keys_reach_commands_and_are_unique() { let mut seen = std::collections::BTreeSet::new(); for (spec, id) in XFIG_KEYS { assert!(parse_xfig_key(spec).is_some(), "{spec}"); assert!( cmd::find(id).is_some() || find_ui(id).is_some(), "{spec} -> unknown {id}" ); assert!(seen.insert(*spec), "{spec} twice"); } assert_eq!(key_label("Shift+Alt+X", false), "Alt+Shift+X"); assert_eq!(key_label("PhysCtrl+L", false), "Ctrl+L"); assert_eq!(key_label("Shift+Alt+X", true), "⌥⇧\u{2009}X"); assert_eq!(key_label("PhysCtrl+L", true), "⌃\u{2009}L"); assert_eq!(xfig_key_label("Z"), "z"); assert_eq!(xfig_accelerator("edit.undo"), Some("Alt+U")); } #[test] fn text_control_keys_follow_xfig() { use crate::platform::Os; let ctrl = egui::Modifiers::CTRL; let key = |os, k, m| match text_key(os, k, m) { Some(ToolEvent::Nav { key, .. }) => Some(key), _ => None, }; assert_eq!(key(Os::Mac, egui::Key::A, ctrl), Some(Key::Home)); assert_eq!(key(Os::Mac, egui::Key::E, ctrl), Some(Key::End)); assert_eq!(key(Os::Mac, egui::Key::K, ctrl), Some(Key::KillToEnd)); assert_eq!(key(Os::Mac, egui::Key::X, ctrl), Some(Key::KillToStart)); assert_eq!(key(Os::Mac, egui::Key::W, ctrl), Some(Key::KillSelection)); // Cmd keeps the platform's meaning on macOS. let cmd = egui::Modifiers { mac_cmd: true, command: true, ..egui::Modifiers::NONE }; assert_eq!(key(Os::Mac, egui::Key::A, cmd), Some(Key::SelectAll)); assert_eq!(key(Os::Mac, egui::Key::K, cmd), None); // Elsewhere Ctrl+A selects all and Ctrl+X is Cut. let ctrl_cmd = egui::Modifiers::CTRL | egui::Modifiers::COMMAND; assert_eq!(key(Os::Linux, egui::Key::A, ctrl_cmd), Some(Key::SelectAll)); assert_eq!(key(Os::Linux, egui::Key::X, ctrl_cmd), None); assert_eq!(key(Os::Linux, egui::Key::K, ctrl_cmd), Some(Key::KillToEnd)); let shift_ctrl = egui::Modifiers::CTRL | egui::Modifiers::SHIFT; assert_eq!(key(Os::Mac, egui::Key::Num6, shift_ctrl), Some(Key::Superscript)); assert_eq!(key(Os::Mac, egui::Key::Minus, shift_ctrl), Some(Key::Subscript)); } #[test] fn menus_show_one_key_xfigs_first() { // macOS: Cmd never clashes with xfig's Ctrl and Option keys. assert_eq!(entry_keys("edit.undo", true, true), "⌥\u{2009}U"); assert_eq!(entry_keys("edit.paste", true, true), "⌥\u{2009}T"); // Linux and Windows: xfig's Ctrl-Z (zoom fit) takes the key. assert_eq!(entry_keys("edit.undo", true, false), "Alt+U"); assert_eq!(entry_keys("edit.paste", true, false), "Alt+T"); assert_eq!(entry_keys("view.zoom_fit", true, false), "Ctrl+Z"); // Without xfig's keys, the platform's shortcuts. assert_eq!(entry_keys("edit.undo", false, false), "Ctrl+Z"); } #[test] fn key_symbols_exist_in_the_fonts() { let ctx = egui::Context::default(); crate::key_font::install(&ctx); let _ = ctx.run(egui::RawInput::default(), |_| {}); let font = egui::FontId::proportional(13.0); // Every symbol the menus draw for keys is in the fonts. let all: String = cmd::all() .iter() .map(|c| c.shortcut) .chain(UI_COMMANDS.iter().map(|c| c.shortcut)) .map(|s| mac_shortcut(s) + &display_shortcut(s)) .chain([THIN.to_string()]) .collect(); for c in all.chars() { assert!(ctx.fonts_mut(|f| f.has_glyph(&font, c)), "no glyph for {c:?}"); } // And the other symbols the UI writes (degrees, check marks, …). for c in "°±²·×—•…↔✔".chars() { assert!(ctx.fonts_mut(|f| f.has_glyph(&font, c)), "no glyph for {c:?}"); } assert_eq!(mac_shortcut("Ctrl+Shift+Z"), "⇧⌘\u{2009}Z"); assert_eq!(mac_shortcut("Ctrl+="), "⌘\u{2009}="); assert_eq!(mac_shortcut("Ctrl+'"), "⌘\u{2009}'"); assert_eq!(mac_shortcut("Ctrl+Alt+K"), "⌥⌘\u{2009}K"); assert_eq!(shortcut_text("Ctrl+Alt+Shift+'", false), "Ctrl+Alt+Shift+'"); assert_eq!(shortcut_text("Ctrl+-", false), "Ctrl+-"); assert_eq!(key_label("PhysCtrl+Alt+K", true), "⌃⌥\u{2009}K"); assert_eq!(mac_shortcut("Ctrl+Shift+Up"), "⇧⌘\u{2009}Up"); assert_eq!(mac_shortcut("Delete"), "Delete"); } #[test] fn shortcuts_parse() { for c in cmd::all() { if !c.shortcut.is_empty() { assert!(parse_shortcut(c.shortcut).is_some(), "{}: {}", c.id, c.shortcut); } } for c in UI_COMMANDS { if !c.shortcut.is_empty() { assert!(parse_shortcut(c.shortcut).is_some(), "{}: {}", c.id, c.shortcut); } } } #[test] fn ui_and_engine_shortcuts_do_not_clash() { let engine: Vec<&str> = cmd::all() .iter() .map(|c| c.shortcut) .filter(|s| !s.is_empty()) .collect(); for c in UI_COMMANDS { assert!( c.shortcut.is_empty() || !engine.contains(&c.shortcut), "{} clashes", c.id ); } } } .