Add app menu

This commit is contained in:
Skyler Lehmkuhl 2025-11-14 18:41:44 -05:00
parent 48da21e062
commit 652b9e6cbb
2 changed files with 553 additions and 429 deletions

View File

@ -20,6 +20,15 @@ fn main() -> eframe::Result {
println!(" - {}: {}", layout.name, layout.description);
}
// Initialize native menus for macOS (app-wide, doesn't need window)
#[cfg(target_os = "macos")]
{
if let Ok(menu_system) = MenuSystem::new() {
menu_system.init_for_macos();
println!("✅ Native macOS menus initialized");
}
}
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1920.0, 1080.0])
@ -180,8 +189,7 @@ struct EditorApp {
fill_color: egui::Color32, // Fill color for drawing
stroke_color: egui::Color32, // Stroke color for drawing
pane_instances: HashMap<NodePath, PaneInstance>, // Pane instances per path
menu_system: Option<MenuSystem>, // Native menu system
menu_initialized: bool, // Track if menu has been initialized with window
menu_system: Option<MenuSystem>, // Native menu system for event checking
}
impl EditorApp {
@ -206,7 +214,6 @@ impl EditorApp {
stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke
pane_instances: HashMap::new(), // Initialize empty, panes created on-demand
menu_system,
menu_initialized: false,
}
}
@ -462,41 +469,27 @@ impl EditorApp {
impl eframe::App for EditorApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Check for native menu events
// Check for native menu events (macOS)
if let Some(menu_system) = &self.menu_system {
if let Some(action) = menu_system.check_events() {
self.handle_menu_action(action);
}
}
// Top menu bar
let mut layout_to_switch: Option<usize> = None;
let current_name = self.current_layout_def().name.clone();
// Check keyboard shortcuts (works on all platforms)
ctx.input(|i| {
if let Some(action) = MenuSystem::check_shortcuts(i) {
self.handle_menu_action(action);
}
});
// Top menu bar (egui-rendered on all platforms)
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("Layout", |ui| {
for (i, layout) in self.layouts.iter().enumerate() {
if ui
.selectable_label(i == self.current_layout_index, &layout.name)
.clicked()
{
layout_to_switch = Some(i);
ui.close_menu();
}
if let Some(action) = MenuSystem::render_egui_menu_bar(ui) {
self.handle_menu_action(action);
}
});
ui.separator();
ui.label(format!("Current: {}", current_name));
});
});
// Switch layout after menu closes to avoid borrow issues
if let Some(index) = layout_to_switch {
self.switch_layout(index);
}
// Main pane area
let mut layout_action: Option<LayoutAction> = None;
egui::CentralPanel::default().show(ctx, |ui| {

View File

@ -2,12 +2,133 @@
///
/// 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, E, G, I, K, L, N, O, Q, S, V, W, X, Z,
// Numbers
Num0,
// Symbols
Comma, Minus, Equals, 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::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::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 {
@ -74,6 +195,242 @@ pub enum MenuAction {
CloseWindow,
}
/// Menu item definition
pub struct MenuItemDef {
pub label: &'static str,
pub action: MenuAction,
pub shortcut: Option<Shortcut>,
}
/// 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;
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, 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 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 };
// 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 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)) };
const QUIT_MACOS: Self = Self { label: "Quit Lightningbeam", action: MenuAction::Quit, shortcut: Some(Shortcut::new(ShortcutKey::Q, CTRL, NO_SHIFT, NO_ALT)) };
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::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::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::Separator,
MenuDef::Item(&Self::SEND_TO_BACK),
MenuDef::Item(&Self::BRING_TO_FRONT),
],
},
// 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::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)]
@ -90,416 +447,61 @@ impl MenuSystem {
// Platform-specific: Add "Lightningbeam" menu on macOS
#[cfg(target_os = "macos")]
{
let app_menu = Submenu::new("Lightningbeam", true);
let about_item = MenuItem::new("About Lightningbeam", true, None);
items.push((about_item.clone(), MenuAction::About));
app_menu.append(&about_item)?;
app_menu.append(&PredefinedMenuItem::separator())?;
let settings_item = MenuItem::new(
"Settings",
true,
Some(Accelerator::new(Some(Modifiers::META), Code::Comma)),
);
items.push((settings_item.clone(), MenuAction::Settings));
app_menu.append(&settings_item)?;
app_menu.append(&PredefinedMenuItem::separator())?;
let close_item = MenuItem::new(
"Close Window",
true,
Some(Accelerator::new(Some(Modifiers::META), Code::KeyW)),
);
items.push((close_item.clone(), MenuAction::CloseWindow));
app_menu.append(&close_item)?;
let quit_item = MenuItem::new(
"Quit Lightningbeam",
true,
Some(Accelerator::new(Some(Modifiers::META), Code::KeyQ)),
);
items.push((quit_item.clone(), MenuAction::Quit));
app_menu.append(&quit_item)?;
menu.append(&app_menu)?;
Self::build_submenu(&menu, &MenuItemDef::macos_app_menu(), &mut items)?;
}
// File menu
let file_menu = Submenu::new("File", true);
let new_file = MenuItem::new(
"New file...",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyN)),
);
items.push((new_file.clone(), MenuAction::NewFile));
file_menu.append(&new_file)?;
let new_window = MenuItem::new(
"New Window",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyN,
)),
);
items.push((new_window.clone(), MenuAction::NewWindow));
file_menu.append(&new_window)?;
file_menu.append(&PredefinedMenuItem::separator())?;
let save = MenuItem::new(
"Save",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyS)),
);
items.push((save.clone(), MenuAction::Save));
file_menu.append(&save)?;
let save_as = MenuItem::new(
"Save As...",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyS,
)),
);
items.push((save_as.clone(), MenuAction::SaveAs));
file_menu.append(&save_as)?;
file_menu.append(&PredefinedMenuItem::separator())?;
// Open Recent submenu (placeholder for now)
let open_recent = Submenu::new("Open Recent", true);
file_menu.append(&open_recent)?;
let open_file = MenuItem::new(
"Open File...",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyO)),
);
items.push((open_file.clone(), MenuAction::OpenFile));
file_menu.append(&open_file)?;
let revert = MenuItem::new("Revert", true, None);
items.push((revert.clone(), MenuAction::Revert));
file_menu.append(&revert)?;
file_menu.append(&PredefinedMenuItem::separator())?;
let import = MenuItem::new(
"Import...",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyI,
)),
);
items.push((import.clone(), MenuAction::Import));
file_menu.append(&import)?;
let export = MenuItem::new(
"Export...",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyE,
)),
);
items.push((export.clone(), MenuAction::Export));
file_menu.append(&export)?;
// On non-macOS, add Quit to File menu
#[cfg(not(target_os = "macos"))]
{
file_menu.append(&PredefinedMenuItem::separator())?;
let quit = MenuItem::new(
"Quit",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyQ)),
);
items.push((quit.clone(), MenuAction::Quit));
file_menu.append(&quit)?;
// Build all menus from the centralized structure
for menu_def in MenuItemDef::menu_structure() {
Self::build_submenu(&menu, menu_def, &mut items)?;
}
menu.append(&file_menu)?;
// Edit menu
let edit_menu = Submenu::new("Edit", true);
let undo = MenuItem::new(
"Undo",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyZ)),
);
items.push((undo.clone(), MenuAction::Undo));
edit_menu.append(&undo)?;
let redo = MenuItem::new(
"Redo",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyZ,
)),
);
items.push((redo.clone(), MenuAction::Redo));
edit_menu.append(&redo)?;
edit_menu.append(&PredefinedMenuItem::separator())?;
let cut = MenuItem::new(
"Cut",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyX)),
);
items.push((cut.clone(), MenuAction::Cut));
edit_menu.append(&cut)?;
let copy = MenuItem::new(
"Copy",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyC)),
);
items.push((copy.clone(), MenuAction::Copy));
edit_menu.append(&copy)?;
let paste = MenuItem::new(
"Paste",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyV)),
);
items.push((paste.clone(), MenuAction::Paste));
edit_menu.append(&paste)?;
let delete = MenuItem::new(
"Delete",
true,
Some(Accelerator::new(None, Code::Delete)),
);
items.push((delete.clone(), MenuAction::Delete));
edit_menu.append(&delete)?;
edit_menu.append(&PredefinedMenuItem::separator())?;
let select_all = MenuItem::new(
"Select All",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyA)),
);
items.push((select_all.clone(), MenuAction::SelectAll));
edit_menu.append(&select_all)?;
let select_none = MenuItem::new(
"Select None",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyA,
)),
);
items.push((select_none.clone(), MenuAction::SelectNone));
edit_menu.append(&select_none)?;
edit_menu.append(&PredefinedMenuItem::separator())?;
let preferences = MenuItem::new("Preferences", true, None);
items.push((preferences.clone(), MenuAction::Preferences));
edit_menu.append(&preferences)?;
menu.append(&edit_menu)?;
// Modify menu
let modify_menu = Submenu::new("Modify", true);
let group = MenuItem::new(
"Group",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyG)),
);
items.push((group.clone(), MenuAction::Group));
modify_menu.append(&group)?;
modify_menu.append(&PredefinedMenuItem::separator())?;
let send_to_back = MenuItem::new("Send to back", true, None);
items.push((send_to_back.clone(), MenuAction::SendToBack));
modify_menu.append(&send_to_back)?;
let bring_to_front = MenuItem::new("Bring to front", true, None);
items.push((bring_to_front.clone(), MenuAction::BringToFront));
modify_menu.append(&bring_to_front)?;
menu.append(&modify_menu)?;
// Layer menu
let layer_menu = Submenu::new("Layer", true);
let add_layer = MenuItem::new(
"Add Layer",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyL,
)),
);
items.push((add_layer.clone(), MenuAction::AddLayer));
layer_menu.append(&add_layer)?;
let add_video_layer = MenuItem::new("Add Video Layer", true, None);
items.push((add_video_layer.clone(), MenuAction::AddVideoLayer));
layer_menu.append(&add_video_layer)?;
let add_audio_track = MenuItem::new("Add Audio Track", true, None);
items.push((add_audio_track.clone(), MenuAction::AddAudioTrack));
layer_menu.append(&add_audio_track)?;
let add_midi_track = MenuItem::new("Add MIDI Track", true, None);
items.push((add_midi_track.clone(), MenuAction::AddMidiTrack));
layer_menu.append(&add_midi_track)?;
layer_menu.append(&PredefinedMenuItem::separator())?;
let delete_layer = MenuItem::new("Delete Layer", true, None);
items.push((delete_layer.clone(), MenuAction::DeleteLayer));
layer_menu.append(&delete_layer)?;
let toggle_layer = MenuItem::new("Hide/Show Layer", true, None);
items.push((toggle_layer.clone(), MenuAction::ToggleLayerVisibility));
layer_menu.append(&toggle_layer)?;
menu.append(&layer_menu)?;
// Timeline menu
let timeline_menu = Submenu::new("Timeline", true);
let new_keyframe = MenuItem::new(
"New Keyframe",
true,
Some(Accelerator::new(None, Code::KeyK)),
);
items.push((new_keyframe.clone(), MenuAction::NewKeyframe));
timeline_menu.append(&new_keyframe)?;
let new_blank_keyframe = MenuItem::new("New Blank Keyframe", true, None);
items.push((new_blank_keyframe.clone(), MenuAction::NewBlankKeyframe));
timeline_menu.append(&new_blank_keyframe)?;
let delete_frame = MenuItem::new("Delete Frame", true, None);
items.push((delete_frame.clone(), MenuAction::DeleteFrame));
timeline_menu.append(&delete_frame)?;
let duplicate_keyframe = MenuItem::new("Duplicate Keyframe", true, None);
items.push((duplicate_keyframe.clone(), MenuAction::DuplicateKeyframe));
timeline_menu.append(&duplicate_keyframe)?;
let add_keyframe_playhead = MenuItem::new("Add Keyframe at Playhead", true, None);
items.push((add_keyframe_playhead.clone(), MenuAction::AddKeyframeAtPlayhead));
timeline_menu.append(&add_keyframe_playhead)?;
timeline_menu.append(&PredefinedMenuItem::separator())?;
let motion_tween = MenuItem::new("Add Motion Tween", true, None);
items.push((motion_tween.clone(), MenuAction::AddMotionTween));
timeline_menu.append(&motion_tween)?;
let shape_tween = MenuItem::new("Add Shape Tween", true, None);
items.push((shape_tween.clone(), MenuAction::AddShapeTween));
timeline_menu.append(&shape_tween)?;
timeline_menu.append(&PredefinedMenuItem::separator())?;
let return_to_start = MenuItem::new("Return to start", true, None);
items.push((return_to_start.clone(), MenuAction::ReturnToStart));
timeline_menu.append(&return_to_start)?;
let play = MenuItem::new("Play", true, None);
items.push((play.clone(), MenuAction::Play));
timeline_menu.append(&play)?;
menu.append(&timeline_menu)?;
// View menu
let view_menu = Submenu::new("View", true);
let zoom_in = MenuItem::new(
"Zoom In",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Equal)),
);
items.push((zoom_in.clone(), MenuAction::ZoomIn));
view_menu.append(&zoom_in)?;
let zoom_out = MenuItem::new(
"Zoom Out",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Minus)),
);
items.push((zoom_out.clone(), MenuAction::ZoomOut));
view_menu.append(&zoom_out)?;
let actual_size = MenuItem::new(
"Actual Size",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Digit0)),
);
items.push((actual_size.clone(), MenuAction::ActualSize));
view_menu.append(&actual_size)?;
let recenter = MenuItem::new("Recenter View", true, None);
items.push((recenter.clone(), MenuAction::RecenterView));
view_menu.append(&recenter)?;
view_menu.append(&PredefinedMenuItem::separator())?;
// Layout submenu
let layout_submenu = Submenu::new("Layout", true);
let next_layout = MenuItem::new(
"Next Layout",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::BracketRight)),
);
items.push((next_layout.clone(), MenuAction::NextLayout));
layout_submenu.append(&next_layout)?;
let prev_layout = MenuItem::new(
"Previous Layout",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::BracketLeft)),
);
items.push((prev_layout.clone(), MenuAction::PreviousLayout));
layout_submenu.append(&prev_layout)?;
// TODO: Add dynamic layout list with checkmarks for current layout
// This will need to be updated when layouts change
view_menu.append(&layout_submenu)?;
menu.append(&view_menu)?;
// Help menu
let help_menu = Submenu::new("Help", true);
let about = MenuItem::new("About...", true, None);
items.push((about.clone(), MenuAction::About));
help_menu.append(&about)?;
menu.append(&help_menu)?;
Ok(Self { menu, items })
}
/// Initialize the menu for the application window
#[cfg(target_os = "linux")]
pub fn init_for_gtk(&self, window: &gtk::ApplicationWindow, container: Option<&gtk::Box>) -> Result<(), Box<dyn std::error::Error>> {
self.menu.init_for_gtk_window(window, container)?;
/// Build a top-level submenu and append to menu
fn build_submenu(
menu: &Menu,
def: &MenuDef,
items: &mut Vec<(MenuItem, MenuAction)>,
) -> Result<(), Box<dyn std::error::Error>> {
if let MenuDef::Submenu { label, children } = def {
let submenu = Submenu::new(*label, true);
for child in *children {
Self::build_menu_item(&submenu, child, items)?;
}
menu.append(&submenu)?;
}
Ok(())
}
/// Initialize the menu for macOS (app-wide)
/// Recursively build menu items within a submenu
fn build_menu_item(
parent: &Submenu,
def: &MenuDef,
items: &mut Vec<(MenuItem, MenuAction)>,
) -> Result<(), Box<dyn std::error::Error>> {
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);
for child in *children {
Self::build_menu_item(&submenu, child, items)?;
}
parent.append(&submenu)?;
}
}
Ok(())
}
/// 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();
@ -517,6 +519,135 @@ impl MenuSystem {
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<MenuAction> {
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(ui: &mut egui::Ui) -> Option<MenuAction> {
let mut action = None;
egui::menu::bar(ui, |ui| {
for menu_def in MenuItemDef::menu_structure() {
if let Some(a) = Self::render_menu_def(ui, menu_def) {
action = Some(a);
}
}
});
action
}
/// Recursively render a MenuDef as egui UI
fn render_menu_def(ui: &mut egui::Ui, def: &MenuDef) -> Option<MenuAction> {
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| {
for child in *children {
if let Some(a) = Self::render_menu_def(ui, child) {
action = Some(a);
ui.close_menu();
}
}
});
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);
if shortcut_text.is_empty() {
ui.add(egui::Button::new(def.label).min_size(egui::vec2(0.0, 0.0))).clicked()
} else {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 20.0; // More space between label and shortcut
let button = ui.add(egui::Button::new(def.label).min_size(egui::vec2(0.0, 0.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(&shortcut_text).weak().size(12.0));
});
button.clicked()
}).inner
}
}
/// 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::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>) {