824 lines
32 KiB
Rust
824 lines
32 KiB
Rust
/// 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<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;
|
|
#[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<Submenu>,
|
|
}
|
|
|
|
impl MenuSystem {
|
|
/// Create a new menu system with all menus and items
|
|
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
|
let menu = Menu::new();
|
|
let mut items = Vec::new();
|
|
let mut open_recent_submenu: Option<Submenu> = 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<Submenu>,
|
|
) -> 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, 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<Submenu>,
|
|
) -> 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);
|
|
|
|
// 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<MenuAction> {
|
|
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<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(&self, ui: &mut egui::Ui, recent_files: &[std::path::PathBuf]) -> Option<MenuAction> {
|
|
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<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| {
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|