/// Native menu implementation using muda /// /// This module creates the native menu bar with all menu items matching /// the JavaScript version's menu structure. /// /// Menu definitions are centralized to allow generating both native menus /// and keyboard shortcut handlers from a single source. use eframe::egui; use muda::{ accelerator::{Accelerator, Code, Modifiers}, Menu, MenuItem, PredefinedMenuItem, Submenu, }; /// Keyboard shortcut definition #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Shortcut { pub key: ShortcutKey, pub ctrl: bool, pub shift: bool, pub alt: bool, } /// Keys that can be used in shortcuts #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShortcutKey { // Letters A, C, D, E, G, I, K, L, N, O, Q, S, V, W, X, Z, // Numbers Num0, // Symbols Comma, Minus, Equals, #[allow(dead_code)] // Completes keyboard mapping set Plus, BracketLeft, BracketRight, // Special Delete, } impl Shortcut { pub const fn new(key: ShortcutKey, ctrl: bool, shift: bool, alt: bool) -> Self { Self { key, ctrl, shift, alt } } /// Convert to muda Accelerator pub fn to_muda_accelerator(&self) -> Accelerator { let mut modifiers = Modifiers::empty(); if self.ctrl { #[cfg(target_os = "macos")] { modifiers |= Modifiers::META; } #[cfg(not(target_os = "macos"))] { modifiers |= Modifiers::CONTROL; } } if self.shift { modifiers |= Modifiers::SHIFT; } if self.alt { modifiers |= Modifiers::ALT; } let code = match self.key { ShortcutKey::A => Code::KeyA, ShortcutKey::C => Code::KeyC, ShortcutKey::D => Code::KeyD, ShortcutKey::E => Code::KeyE, ShortcutKey::G => Code::KeyG, ShortcutKey::I => Code::KeyI, ShortcutKey::K => Code::KeyK, ShortcutKey::L => Code::KeyL, ShortcutKey::N => Code::KeyN, ShortcutKey::O => Code::KeyO, ShortcutKey::Q => Code::KeyQ, ShortcutKey::S => Code::KeyS, ShortcutKey::V => Code::KeyV, ShortcutKey::W => Code::KeyW, ShortcutKey::X => Code::KeyX, ShortcutKey::Z => Code::KeyZ, ShortcutKey::Num0 => Code::Digit0, ShortcutKey::Comma => Code::Comma, ShortcutKey::Minus => Code::Minus, ShortcutKey::Equals => Code::Equal, ShortcutKey::Plus => Code::Equal, // Same key as equals ShortcutKey::BracketLeft => Code::BracketLeft, ShortcutKey::BracketRight => Code::BracketRight, ShortcutKey::Delete => Code::Delete, }; Accelerator::new(if modifiers.is_empty() { None } else { Some(modifiers) }, code) } /// Check if this shortcut matches the current egui input state pub fn matches_egui_input(&self, input: &egui::InputState) -> bool { // Check modifiers first if self.ctrl != input.modifiers.ctrl { return false; } if self.shift != input.modifiers.shift { return false; } if self.alt != input.modifiers.alt { return false; } // Check key let key = match self.key { ShortcutKey::A => egui::Key::A, ShortcutKey::C => egui::Key::C, ShortcutKey::D => egui::Key::D, ShortcutKey::E => egui::Key::E, ShortcutKey::G => egui::Key::G, ShortcutKey::I => egui::Key::I, ShortcutKey::K => egui::Key::K, ShortcutKey::L => egui::Key::L, ShortcutKey::N => egui::Key::N, ShortcutKey::O => egui::Key::O, ShortcutKey::Q => egui::Key::Q, ShortcutKey::S => egui::Key::S, ShortcutKey::V => egui::Key::V, ShortcutKey::W => egui::Key::W, ShortcutKey::X => egui::Key::X, ShortcutKey::Z => egui::Key::Z, ShortcutKey::Num0 => egui::Key::Num0, ShortcutKey::Comma => egui::Key::Comma, ShortcutKey::Minus => egui::Key::Minus, ShortcutKey::Equals => egui::Key::Equals, ShortcutKey::Plus => egui::Key::Plus, ShortcutKey::BracketLeft => egui::Key::OpenBracket, ShortcutKey::BracketRight => egui::Key::CloseBracket, ShortcutKey::Delete => egui::Key::Delete, }; input.key_pressed(key) } } /// All possible menu actions that can be triggered #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MenuAction { // File menu NewFile, NewWindow, Save, SaveAs, OpenFile, OpenRecent(usize), // Index into recent files list ClearRecentFiles, // Clear recent files list Revert, Import, ImportToLibrary, Export, Quit, // Edit menu Undo, Redo, Cut, Copy, Paste, Delete, SelectAll, SelectNone, Preferences, // Modify menu Group, ConvertToMovieClip, SendToBack, BringToFront, SplitClip, DuplicateClip, // Layer menu AddLayer, AddVideoLayer, AddAudioTrack, AddMidiTrack, AddTestClip, // For testing: adds a test clip to the asset library DeleteLayer, ToggleLayerVisibility, // Timeline menu NewKeyframe, NewBlankKeyframe, DeleteFrame, DuplicateKeyframe, AddKeyframeAtPlayhead, AddMotionTween, AddShapeTween, ReturnToStart, Play, // View menu ZoomIn, ZoomOut, ActualSize, RecenterView, NextLayout, PreviousLayout, #[allow(dead_code)] // Handler exists in main.rs, menu item not yet wired SwitchLayout(usize), // Help menu About, // Lightningbeam menu (macOS) Settings, CloseWindow, } /// Menu item definition pub struct MenuItemDef { pub label: &'static str, pub action: MenuAction, pub shortcut: Option, } /// Menu structure definition - can be an item, separator, or submenu pub enum MenuDef { Item(&'static MenuItemDef), Separator, Submenu { label: &'static str, children: &'static [MenuDef], }, } // Shortcut constants for clarity const CTRL: bool = true; const SHIFT: bool = true; #[allow(dead_code)] const ALT: bool = true; const NO_CTRL: bool = false; const NO_SHIFT: bool = false; const NO_ALT: bool = false; // Central menu definitions - single source of truth impl MenuItemDef { // File menu items const NEW_FILE: Self = Self { label: "New file...", action: MenuAction::NewFile, shortcut: Some(Shortcut::new(ShortcutKey::N, CTRL, NO_SHIFT, NO_ALT)) }; const NEW_WINDOW: Self = Self { label: "New Window", action: MenuAction::NewWindow, shortcut: Some(Shortcut::new(ShortcutKey::N, CTRL, SHIFT, NO_ALT)) }; const SAVE: Self = Self { label: "Save", action: MenuAction::Save, shortcut: Some(Shortcut::new(ShortcutKey::S, CTRL, NO_SHIFT, NO_ALT)) }; const SAVE_AS: Self = Self { label: "Save As...", action: MenuAction::SaveAs, shortcut: Some(Shortcut::new(ShortcutKey::S, CTRL, SHIFT, NO_ALT)) }; const OPEN_FILE: Self = Self { label: "Open File...", action: MenuAction::OpenFile, shortcut: Some(Shortcut::new(ShortcutKey::O, CTRL, NO_SHIFT, NO_ALT)) }; const REVERT: Self = Self { label: "Revert", action: MenuAction::Revert, shortcut: None }; const IMPORT: Self = Self { label: "Import...", action: MenuAction::Import, shortcut: Some(Shortcut::new(ShortcutKey::I, CTRL, NO_SHIFT, NO_ALT)) }; const IMPORT_TO_LIBRARY: Self = Self { label: "Import to Library...", action: MenuAction::ImportToLibrary, shortcut: Some(Shortcut::new(ShortcutKey::I, CTRL, SHIFT, NO_ALT)) }; const EXPORT: Self = Self { label: "Export...", action: MenuAction::Export, shortcut: Some(Shortcut::new(ShortcutKey::E, CTRL, SHIFT, NO_ALT)) }; const QUIT: Self = Self { label: "Quit", action: MenuAction::Quit, shortcut: Some(Shortcut::new(ShortcutKey::Q, CTRL, NO_SHIFT, NO_ALT)) }; // Edit menu items const UNDO: Self = Self { label: "Undo", action: MenuAction::Undo, shortcut: Some(Shortcut::new(ShortcutKey::Z, CTRL, NO_SHIFT, NO_ALT)) }; const REDO: Self = Self { label: "Redo", action: MenuAction::Redo, shortcut: Some(Shortcut::new(ShortcutKey::Z, CTRL, SHIFT, NO_ALT)) }; const CUT: Self = Self { label: "Cut", action: MenuAction::Cut, shortcut: Some(Shortcut::new(ShortcutKey::X, CTRL, NO_SHIFT, NO_ALT)) }; const COPY: Self = Self { label: "Copy", action: MenuAction::Copy, shortcut: Some(Shortcut::new(ShortcutKey::C, CTRL, NO_SHIFT, NO_ALT)) }; const PASTE: Self = Self { label: "Paste", action: MenuAction::Paste, shortcut: Some(Shortcut::new(ShortcutKey::V, CTRL, NO_SHIFT, NO_ALT)) }; const DELETE: Self = Self { label: "Delete", action: MenuAction::Delete, shortcut: Some(Shortcut::new(ShortcutKey::Delete, NO_CTRL, NO_SHIFT, NO_ALT)) }; const SELECT_ALL: Self = Self { label: "Select All", action: MenuAction::SelectAll, shortcut: Some(Shortcut::new(ShortcutKey::A, CTRL, NO_SHIFT, NO_ALT)) }; const SELECT_NONE: Self = Self { label: "Select None", action: MenuAction::SelectNone, shortcut: Some(Shortcut::new(ShortcutKey::A, CTRL, SHIFT, NO_ALT)) }; const PREFERENCES: Self = Self { label: "Preferences", action: MenuAction::Preferences, shortcut: None }; // Modify menu items const GROUP: Self = Self { label: "Group", action: MenuAction::Group, shortcut: Some(Shortcut::new(ShortcutKey::G, CTRL, NO_SHIFT, NO_ALT)) }; const CONVERT_TO_MOVIE_CLIP: Self = Self { label: "Convert to Movie Clip", action: MenuAction::ConvertToMovieClip, shortcut: None }; const SEND_TO_BACK: Self = Self { label: "Send to back", action: MenuAction::SendToBack, shortcut: None }; const BRING_TO_FRONT: Self = Self { label: "Bring to front", action: MenuAction::BringToFront, shortcut: None }; const SPLIT_CLIP: Self = Self { label: "Split Clip", action: MenuAction::SplitClip, shortcut: Some(Shortcut::new(ShortcutKey::K, CTRL, NO_SHIFT, NO_ALT)) }; const DUPLICATE_CLIP: Self = Self { label: "Duplicate Clip", action: MenuAction::DuplicateClip, shortcut: Some(Shortcut::new(ShortcutKey::D, CTRL, NO_SHIFT, NO_ALT)) }; // Layer menu items const ADD_LAYER: Self = Self { label: "Add Layer", action: MenuAction::AddLayer, shortcut: Some(Shortcut::new(ShortcutKey::L, CTRL, SHIFT, NO_ALT)) }; const ADD_VIDEO_LAYER: Self = Self { label: "Add Video Layer", action: MenuAction::AddVideoLayer, shortcut: None }; const ADD_AUDIO_TRACK: Self = Self { label: "Add Audio Track", action: MenuAction::AddAudioTrack, shortcut: None }; const ADD_MIDI_TRACK: Self = Self { label: "Add MIDI Track", action: MenuAction::AddMidiTrack, shortcut: None }; const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip to Library", action: MenuAction::AddTestClip, shortcut: None }; const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None }; const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None }; // Timeline menu items const NEW_KEYFRAME: Self = Self { label: "New Keyframe", action: MenuAction::NewKeyframe, shortcut: Some(Shortcut::new(ShortcutKey::K, NO_CTRL, NO_SHIFT, NO_ALT)) }; const NEW_BLANK_KEYFRAME: Self = Self { label: "New Blank Keyframe", action: MenuAction::NewBlankKeyframe, shortcut: None }; const DELETE_FRAME: Self = Self { label: "Delete Frame", action: MenuAction::DeleteFrame, shortcut: None }; const DUPLICATE_KEYFRAME: Self = Self { label: "Duplicate Keyframe", action: MenuAction::DuplicateKeyframe, shortcut: None }; const ADD_KEYFRAME_AT_PLAYHEAD: Self = Self { label: "Add Keyframe at Playhead", action: MenuAction::AddKeyframeAtPlayhead, shortcut: None }; const ADD_MOTION_TWEEN: Self = Self { label: "Add Motion Tween", action: MenuAction::AddMotionTween, shortcut: None }; const ADD_SHAPE_TWEEN: Self = Self { label: "Add Shape Tween", action: MenuAction::AddShapeTween, shortcut: None }; const RETURN_TO_START: Self = Self { label: "Return to start", action: MenuAction::ReturnToStart, shortcut: None }; const PLAY: Self = Self { label: "Play", action: MenuAction::Play, shortcut: None }; // View menu items const ZOOM_IN: Self = Self { label: "Zoom In", action: MenuAction::ZoomIn, shortcut: Some(Shortcut::new(ShortcutKey::Equals, CTRL, NO_SHIFT, NO_ALT)) }; const ZOOM_OUT: Self = Self { label: "Zoom Out", action: MenuAction::ZoomOut, shortcut: Some(Shortcut::new(ShortcutKey::Minus, CTRL, NO_SHIFT, NO_ALT)) }; const ACTUAL_SIZE: Self = Self { label: "Actual Size", action: MenuAction::ActualSize, shortcut: Some(Shortcut::new(ShortcutKey::Num0, CTRL, NO_SHIFT, NO_ALT)) }; const RECENTER_VIEW: Self = Self { label: "Recenter View", action: MenuAction::RecenterView, shortcut: None }; const NEXT_LAYOUT: Self = Self { label: "Next Layout", action: MenuAction::NextLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketRight, CTRL, NO_SHIFT, NO_ALT)) }; const PREVIOUS_LAYOUT: Self = Self { label: "Previous Layout", action: MenuAction::PreviousLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketLeft, CTRL, NO_SHIFT, NO_ALT)) }; // Help menu items const ABOUT: Self = Self { label: "About...", action: MenuAction::About, shortcut: None }; // macOS app menu items const SETTINGS: Self = Self { label: "Settings", action: MenuAction::Settings, shortcut: Some(Shortcut::new(ShortcutKey::Comma, CTRL, NO_SHIFT, NO_ALT)) }; const CLOSE_WINDOW: Self = Self { label: "Close Window", action: MenuAction::CloseWindow, shortcut: Some(Shortcut::new(ShortcutKey::W, CTRL, NO_SHIFT, NO_ALT)) }; #[allow(dead_code)] // Used in #[cfg(target_os = "macos")] block const QUIT_MACOS: Self = Self { label: "Quit Lightningbeam", action: MenuAction::Quit, shortcut: Some(Shortcut::new(ShortcutKey::Q, CTRL, NO_SHIFT, NO_ALT)) }; #[allow(dead_code)] const ABOUT_MACOS: Self = Self { label: "About Lightningbeam", action: MenuAction::About, shortcut: None }; /// Get all menu items with shortcuts (for keyboard handling) pub fn all_with_shortcuts() -> &'static [&'static MenuItemDef] { &[ &Self::NEW_FILE, &Self::NEW_WINDOW, &Self::SAVE, &Self::SAVE_AS, &Self::OPEN_FILE, &Self::IMPORT, &Self::IMPORT_TO_LIBRARY, &Self::EXPORT, &Self::QUIT, &Self::UNDO, &Self::REDO, &Self::CUT, &Self::COPY, &Self::PASTE, &Self::DELETE, &Self::SELECT_ALL, &Self::SELECT_NONE, &Self::GROUP, &Self::ADD_LAYER, &Self::NEW_KEYFRAME, &Self::ZOOM_IN, &Self::ZOOM_OUT, &Self::ACTUAL_SIZE, &Self::NEXT_LAYOUT, &Self::PREVIOUS_LAYOUT, &Self::SETTINGS, &Self::CLOSE_WINDOW, ] } /// Get the complete menu structure definition pub const fn menu_structure() -> &'static [MenuDef] { &[ // File menu MenuDef::Submenu { label: "File", children: &[ MenuDef::Item(&Self::NEW_FILE), MenuDef::Item(&Self::NEW_WINDOW), MenuDef::Separator, MenuDef::Item(&Self::SAVE), MenuDef::Item(&Self::SAVE_AS), MenuDef::Separator, MenuDef::Submenu { label: "Open Recent", children: &[], // TODO: Dynamic recent files }, MenuDef::Item(&Self::OPEN_FILE), MenuDef::Item(&Self::REVERT), MenuDef::Separator, MenuDef::Item(&Self::IMPORT), MenuDef::Item(&Self::IMPORT_TO_LIBRARY), MenuDef::Item(&Self::EXPORT), #[cfg(not(target_os = "macos"))] MenuDef::Separator, #[cfg(not(target_os = "macos"))] MenuDef::Item(&Self::QUIT), ], }, // Edit menu MenuDef::Submenu { label: "Edit", children: &[ MenuDef::Item(&Self::UNDO), MenuDef::Item(&Self::REDO), MenuDef::Separator, MenuDef::Item(&Self::CUT), MenuDef::Item(&Self::COPY), MenuDef::Item(&Self::PASTE), MenuDef::Item(&Self::DELETE), MenuDef::Separator, MenuDef::Item(&Self::SELECT_ALL), MenuDef::Item(&Self::SELECT_NONE), MenuDef::Separator, MenuDef::Item(&Self::PREFERENCES), ], }, // Modify menu MenuDef::Submenu { label: "Modify", children: &[ MenuDef::Item(&Self::GROUP), MenuDef::Item(&Self::CONVERT_TO_MOVIE_CLIP), MenuDef::Separator, MenuDef::Item(&Self::SEND_TO_BACK), MenuDef::Item(&Self::BRING_TO_FRONT), MenuDef::Separator, MenuDef::Item(&Self::SPLIT_CLIP), MenuDef::Item(&Self::DUPLICATE_CLIP), ], }, // Layer menu MenuDef::Submenu { label: "Layer", children: &[ MenuDef::Item(&Self::ADD_LAYER), MenuDef::Item(&Self::ADD_VIDEO_LAYER), MenuDef::Item(&Self::ADD_AUDIO_TRACK), MenuDef::Item(&Self::ADD_MIDI_TRACK), MenuDef::Separator, MenuDef::Item(&Self::ADD_TEST_CLIP), MenuDef::Separator, MenuDef::Item(&Self::DELETE_LAYER), MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY), ], }, // Timeline menu MenuDef::Submenu { label: "Timeline", children: &[ MenuDef::Item(&Self::NEW_KEYFRAME), MenuDef::Item(&Self::NEW_BLANK_KEYFRAME), MenuDef::Item(&Self::DELETE_FRAME), MenuDef::Item(&Self::DUPLICATE_KEYFRAME), MenuDef::Item(&Self::ADD_KEYFRAME_AT_PLAYHEAD), MenuDef::Separator, MenuDef::Item(&Self::ADD_MOTION_TWEEN), MenuDef::Item(&Self::ADD_SHAPE_TWEEN), MenuDef::Separator, MenuDef::Item(&Self::RETURN_TO_START), MenuDef::Item(&Self::PLAY), ], }, // View menu MenuDef::Submenu { label: "View", children: &[ MenuDef::Item(&Self::ZOOM_IN), MenuDef::Item(&Self::ZOOM_OUT), MenuDef::Item(&Self::ACTUAL_SIZE), MenuDef::Item(&Self::RECENTER_VIEW), MenuDef::Separator, MenuDef::Submenu { label: "Layout", children: &[ MenuDef::Item(&Self::NEXT_LAYOUT), MenuDef::Item(&Self::PREVIOUS_LAYOUT), // TODO: Dynamic layout list ], }, ], }, // Help menu MenuDef::Submenu { label: "Help", children: &[ MenuDef::Item(&Self::ABOUT), ], }, ] } /// Get macOS app menu structure #[cfg(target_os = "macos")] pub const fn macos_app_menu() -> MenuDef { MenuDef::Submenu { label: "Lightningbeam", children: &[ MenuDef::Item(&Self::ABOUT_MACOS), MenuDef::Separator, MenuDef::Item(&Self::SETTINGS), MenuDef::Separator, MenuDef::Item(&Self::CLOSE_WINDOW), MenuDef::Item(&Self::QUIT_MACOS), ], } } } /// Menu system that holds all menu items and can dispatch actions pub struct MenuSystem { #[allow(dead_code)] menu: Menu, items: Vec<(MenuItem, MenuAction)>, /// Reference to "Open Recent" submenu for dynamic updates open_recent_submenu: Option, } impl MenuSystem { /// Create a new menu system with all menus and items pub fn new() -> Result> { let menu = Menu::new(); let mut items = Vec::new(); let mut open_recent_submenu: Option = None; // Platform-specific: Add "Lightningbeam" menu on macOS #[cfg(target_os = "macos")] { Self::build_submenu(&menu, &MenuItemDef::macos_app_menu(), &mut items, &mut open_recent_submenu)?; } // Build all menus from the centralized structure for menu_def in MenuItemDef::menu_structure() { Self::build_submenu(&menu, menu_def, &mut items, &mut open_recent_submenu)?; } Ok(Self { menu, items, open_recent_submenu }) } /// Build a top-level submenu and append to menu fn build_submenu( menu: &Menu, def: &MenuDef, items: &mut Vec<(MenuItem, MenuAction)>, open_recent_submenu: &mut Option, ) -> Result<(), Box> { if let MenuDef::Submenu { label, children } = def { let submenu = Submenu::new(*label, true); for child in *children { Self::build_menu_item(&submenu, child, items, open_recent_submenu)?; } menu.append(&submenu)?; } Ok(()) } /// Recursively build menu items within a submenu fn build_menu_item( parent: &Submenu, def: &MenuDef, items: &mut Vec<(MenuItem, MenuAction)>, open_recent_submenu: &mut Option, ) -> Result<(), Box> { match def { MenuDef::Item(item_def) => { let accelerator = item_def.shortcut.as_ref().map(|s| s.to_muda_accelerator()); let item = MenuItem::new(item_def.label, true, accelerator); items.push((item.clone(), item_def.action)); parent.append(&item)?; } MenuDef::Separator => { parent.append(&PredefinedMenuItem::separator())?; } MenuDef::Submenu { label, children } => { let submenu = Submenu::new(*label, true); // Capture reference if this is "Open Recent" if *label == "Open Recent" { *open_recent_submenu = Some(submenu.clone()); } for child in *children { Self::build_menu_item(&submenu, child, items, open_recent_submenu)?; } parent.append(&submenu)?; } } Ok(()) } /// Update "Open Recent" submenu with current recent files /// Call this after menu creation and whenever recent files change pub fn update_recent_files(&mut self, recent_files: &[std::path::PathBuf]) { if let Some(submenu) = &self.open_recent_submenu { // Clear existing items while submenu.items().len() > 0 { let _ = submenu.remove_at(0); } // Add recent file items for (index, path) in recent_files.iter().enumerate() { let display_name = path .file_name() .and_then(|s| s.to_str()) .unwrap_or("Unknown") .to_string(); let item = MenuItem::new(&display_name, true, None); if submenu.append(&item).is_ok() { self.items.push((item.clone(), MenuAction::OpenRecent(index))); } } // Add separator and clear option if we have items if !recent_files.is_empty() { let _ = submenu.append(&PredefinedMenuItem::separator()); } // Add "Clear Recent Files" item let clear_item = MenuItem::new("Clear Recent Files", true, None); if submenu.append(&clear_item).is_ok() { self.items.push((clear_item.clone(), MenuAction::ClearRecentFiles)); } } } /// Initialize native menus for macOS (app-wide, doesn't require window handle) #[cfg(target_os = "macos")] pub fn init_for_macos(&self) { self.menu.init_for_nsapp(); } /// Check if any menu item was triggered and return the action pub fn check_events(&self) -> Option { for (item, action) in &self.items { if let Ok(event) = muda::MenuEvent::receiver().try_recv() { if event.id == item.id() { return Some(*action); } } } None } /// Check keyboard shortcuts from egui input and return the action /// This works cross-platform and complements native menus pub fn check_shortcuts(input: &egui::InputState) -> Option { for def in MenuItemDef::all_with_shortcuts() { if let Some(shortcut) = &def.shortcut { if shortcut.matches_egui_input(input) { return Some(def.action); } } } None } /// Render egui menu bar from the same menu structure (for Linux/Windows) pub fn render_egui_menu_bar(&self, ui: &mut egui::Ui, recent_files: &[std::path::PathBuf]) -> Option { let mut action = None; egui::MenuBar::new().ui(ui, |ui| { for menu_def in MenuItemDef::menu_structure() { if let Some(a) = self.render_menu_def(ui, menu_def, recent_files) { action = Some(a); } } }); action } /// Recursively render a MenuDef as egui UI fn render_menu_def(&self, ui: &mut egui::Ui, def: &MenuDef, recent_files: &[std::path::PathBuf]) -> Option { match def { MenuDef::Item(item_def) => { if Self::render_menu_item(ui, item_def) { Some(item_def.action) } else { None } } MenuDef::Separator => { ui.separator(); None } MenuDef::Submenu { label, children } => { let mut action = None; ui.menu_button(*label, |ui| { // Special handling for "Open Recent" submenu if *label == "Open Recent" { // Render dynamic recent files for (index, path) in recent_files.iter().enumerate() { let display_name = path .file_name() .and_then(|s| s.to_str()) .unwrap_or("Unknown"); if ui.button(display_name).clicked() { action = Some(MenuAction::OpenRecent(index)); ui.close(); } } // Add separator and clear option if we have items if !recent_files.is_empty() { ui.separator(); } if ui.button("Clear Recent Files").clicked() { action = Some(MenuAction::ClearRecentFiles); ui.close(); } } else { // Normal submenu rendering for child in *children { if let Some(a) = self.render_menu_def(ui, child, recent_files) { action = Some(a); ui.close(); } } } }); action } } } /// Render a single menu item with label and shortcut fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef) -> bool { let shortcut_text = if let Some(shortcut) = &def.shortcut { Self::format_shortcut(shortcut) } else { String::new() }; // Set minimum width for menu items to prevent cramping ui.set_min_width(180.0); let desired_width = ui.available_width(); let (rect, response) = ui.allocate_exact_size( egui::vec2(desired_width, ui.spacing().interact_size.y), egui::Sense::click(), ); if ui.is_rect_visible(rect) { // Highlight on hover if response.hovered() { ui.painter().rect_filled(rect, 2.0, ui.visuals().widgets.hovered.bg_fill); } // Draw label text left-aligned let text_color = if response.hovered() { ui.visuals().widgets.hovered.text_color() } else { ui.visuals().widgets.inactive.text_color() }; let label_pos = rect.min + egui::vec2(4.0, (rect.height() - 14.0) / 2.0); ui.painter().text( label_pos, egui::Align2::LEFT_TOP, def.label, egui::FontId::proportional(14.0), text_color, ); // Draw shortcut text right-aligned if !shortcut_text.is_empty() { let shortcut_pos = rect.max - egui::vec2(4.0, (rect.height() - 12.0) / 2.0); ui.painter().text( shortcut_pos, egui::Align2::RIGHT_BOTTOM, &shortcut_text, egui::FontId::proportional(12.0), ui.visuals().weak_text_color(), ); } } response.clicked() } /// Format shortcut for display (e.g., "Ctrl+S") fn format_shortcut(shortcut: &Shortcut) -> String { let mut parts = Vec::new(); if shortcut.ctrl { parts.push("Ctrl"); } if shortcut.shift { parts.push("Shift"); } if shortcut.alt { parts.push("Alt"); } let key_name = match shortcut.key { ShortcutKey::A => "A", ShortcutKey::C => "C", ShortcutKey::D => "D", ShortcutKey::E => "E", ShortcutKey::G => "G", ShortcutKey::I => "I", ShortcutKey::K => "K", ShortcutKey::L => "L", ShortcutKey::N => "N", ShortcutKey::O => "O", ShortcutKey::Q => "Q", ShortcutKey::S => "S", ShortcutKey::V => "V", ShortcutKey::W => "W", ShortcutKey::X => "X", ShortcutKey::Z => "Z", ShortcutKey::Num0 => "0", ShortcutKey::Comma => ",", ShortcutKey::Minus => "-", ShortcutKey::Equals => "=", ShortcutKey::Plus => "+", ShortcutKey::BracketLeft => "[", ShortcutKey::BracketRight => "]", ShortcutKey::Delete => "Del", }; parts.push(key_name); parts.join("+") } /// Update menu item text dynamically (e.g., for Undo/Redo with action names) #[allow(dead_code)] pub fn update_undo_text(&self, action_name: Option<&str>) { // Find the Undo menu item and update its text for (item, action) in &self.items { if *action == MenuAction::Undo { let text = if let Some(name) = action_name { format!("Undo {}", name) } else { "Undo".to_string() }; let _ = item.set_text(text); break; } } } /// Update menu item text dynamically for Redo #[allow(dead_code)] pub fn update_redo_text(&self, action_name: Option<&str>) { for (item, action) in &self.items { if *action == MenuAction::Redo { let text = if let Some(name) = action_name { format!("Redo {}", name) } else { "Redo".to_string() }; let _ = item.set_text(text); break; } } } /// Enable or disable a menu item #[allow(dead_code)] pub fn set_enabled(&self, action: MenuAction, enabled: bool) { for (item, item_action) in &self.items { if *item_action == action { let _ = item.set_enabled(enabled); break; } } } }