1088 lines
48 KiB
Rust
1088 lines
48 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, Hash, serde::Serialize, serde::Deserialize)]
|
|
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, Hash, serde::Serialize, serde::Deserialize)]
|
|
pub enum ShortcutKey {
|
|
// Letters
|
|
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z,
|
|
// Digits
|
|
Num0, Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9,
|
|
// Function keys
|
|
F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12,
|
|
// Arrow keys
|
|
ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
|
|
// Symbols
|
|
Comma, Minus, Equals,
|
|
#[allow(dead_code)] // Completes keyboard mapping set
|
|
Plus,
|
|
BracketLeft, BracketRight,
|
|
Semicolon, Quote, Period, Slash, Backtick,
|
|
// Special
|
|
Space, Escape, Enter, Tab, Backspace, Delete,
|
|
Home, End, PageUp, PageDown,
|
|
}
|
|
|
|
impl ShortcutKey {
|
|
/// Convert to the corresponding `egui::Key`.
|
|
///
|
|
/// Note: we maintain our own `ShortcutKey` enum rather than using `egui::Key` directly
|
|
/// because `egui::Key` only implements `serde::{Serialize, Deserialize}` behind the
|
|
/// `serde` cargo feature, which we do not enable for egui. Enabling it would couple
|
|
/// our persisted config format to egui's internal variant names, which could change
|
|
/// between egui version upgrades and silently break user keybind files. `ShortcutKey`
|
|
/// gives us a stable, self-owned serialization surface. The tradeoff is this one
|
|
/// exhaustive mapping; the display and input-matching methods below both delegate to
|
|
/// `egui::Key` so there is no further duplication.
|
|
pub fn to_egui_key(self) -> egui::Key {
|
|
match self {
|
|
Self::A => egui::Key::A, Self::B => egui::Key::B, Self::C => egui::Key::C,
|
|
Self::D => egui::Key::D, Self::E => egui::Key::E, Self::F => egui::Key::F,
|
|
Self::G => egui::Key::G, Self::H => egui::Key::H, Self::I => egui::Key::I,
|
|
Self::J => egui::Key::J, Self::K => egui::Key::K, Self::L => egui::Key::L,
|
|
Self::M => egui::Key::M, Self::N => egui::Key::N, Self::O => egui::Key::O,
|
|
Self::P => egui::Key::P, Self::Q => egui::Key::Q, Self::R => egui::Key::R,
|
|
Self::S => egui::Key::S, Self::T => egui::Key::T, Self::U => egui::Key::U,
|
|
Self::V => egui::Key::V, Self::W => egui::Key::W, Self::X => egui::Key::X,
|
|
Self::Y => egui::Key::Y, Self::Z => egui::Key::Z,
|
|
Self::Num0 => egui::Key::Num0, Self::Num1 => egui::Key::Num1,
|
|
Self::Num2 => egui::Key::Num2, Self::Num3 => egui::Key::Num3,
|
|
Self::Num4 => egui::Key::Num4, Self::Num5 => egui::Key::Num5,
|
|
Self::Num6 => egui::Key::Num6, Self::Num7 => egui::Key::Num7,
|
|
Self::Num8 => egui::Key::Num8, Self::Num9 => egui::Key::Num9,
|
|
Self::F1 => egui::Key::F1, Self::F2 => egui::Key::F2,
|
|
Self::F3 => egui::Key::F3, Self::F4 => egui::Key::F4,
|
|
Self::F5 => egui::Key::F5, Self::F6 => egui::Key::F6,
|
|
Self::F7 => egui::Key::F7, Self::F8 => egui::Key::F8,
|
|
Self::F9 => egui::Key::F9, Self::F10 => egui::Key::F10,
|
|
Self::F11 => egui::Key::F11, Self::F12 => egui::Key::F12,
|
|
Self::ArrowUp => egui::Key::ArrowUp, Self::ArrowDown => egui::Key::ArrowDown,
|
|
Self::ArrowLeft => egui::Key::ArrowLeft, Self::ArrowRight => egui::Key::ArrowRight,
|
|
Self::Comma => egui::Key::Comma, Self::Minus => egui::Key::Minus,
|
|
Self::Equals => egui::Key::Equals, Self::Plus => egui::Key::Plus,
|
|
Self::BracketLeft => egui::Key::OpenBracket,
|
|
Self::BracketRight => egui::Key::CloseBracket,
|
|
Self::Semicolon => egui::Key::Semicolon, Self::Quote => egui::Key::Quote,
|
|
Self::Period => egui::Key::Period, Self::Slash => egui::Key::Slash,
|
|
Self::Backtick => egui::Key::Backtick,
|
|
Self::Space => egui::Key::Space, Self::Escape => egui::Key::Escape,
|
|
Self::Enter => egui::Key::Enter, Self::Tab => egui::Key::Tab,
|
|
Self::Backspace => egui::Key::Backspace, Self::Delete => egui::Key::Delete,
|
|
Self::Home => egui::Key::Home, Self::End => egui::Key::End,
|
|
Self::PageUp => egui::Key::PageUp, Self::PageDown => egui::Key::PageDown,
|
|
}
|
|
}
|
|
|
|
/// Short human-readable name for this key (e.g. "A", "F1", "Delete").
|
|
/// Delegates to `egui::Key::name()` so the strings stay consistent with
|
|
/// what egui itself would display.
|
|
pub fn display_name(self) -> &'static str {
|
|
self.to_egui_key().name()
|
|
}
|
|
|
|
/// Try to convert an egui Key to a ShortcutKey
|
|
pub fn from_egui_key(key: egui::Key) -> Option<Self> {
|
|
Some(match key {
|
|
egui::Key::A => Self::A, egui::Key::B => Self::B, egui::Key::C => Self::C,
|
|
egui::Key::D => Self::D, egui::Key::E => Self::E, egui::Key::F => Self::F,
|
|
egui::Key::G => Self::G, egui::Key::H => Self::H, egui::Key::I => Self::I,
|
|
egui::Key::J => Self::J, egui::Key::K => Self::K, egui::Key::L => Self::L,
|
|
egui::Key::M => Self::M, egui::Key::N => Self::N, egui::Key::O => Self::O,
|
|
egui::Key::P => Self::P, egui::Key::Q => Self::Q, egui::Key::R => Self::R,
|
|
egui::Key::S => Self::S, egui::Key::T => Self::T, egui::Key::U => Self::U,
|
|
egui::Key::V => Self::V, egui::Key::W => Self::W, egui::Key::X => Self::X,
|
|
egui::Key::Y => Self::Y, egui::Key::Z => Self::Z,
|
|
egui::Key::Num0 => Self::Num0, egui::Key::Num1 => Self::Num1,
|
|
egui::Key::Num2 => Self::Num2, egui::Key::Num3 => Self::Num3,
|
|
egui::Key::Num4 => Self::Num4, egui::Key::Num5 => Self::Num5,
|
|
egui::Key::Num6 => Self::Num6, egui::Key::Num7 => Self::Num7,
|
|
egui::Key::Num8 => Self::Num8, egui::Key::Num9 => Self::Num9,
|
|
egui::Key::F1 => Self::F1, egui::Key::F2 => Self::F2,
|
|
egui::Key::F3 => Self::F3, egui::Key::F4 => Self::F4,
|
|
egui::Key::F5 => Self::F5, egui::Key::F6 => Self::F6,
|
|
egui::Key::F7 => Self::F7, egui::Key::F8 => Self::F8,
|
|
egui::Key::F9 => Self::F9, egui::Key::F10 => Self::F10,
|
|
egui::Key::F11 => Self::F11, egui::Key::F12 => Self::F12,
|
|
egui::Key::ArrowUp => Self::ArrowUp, egui::Key::ArrowDown => Self::ArrowDown,
|
|
egui::Key::ArrowLeft => Self::ArrowLeft, egui::Key::ArrowRight => Self::ArrowRight,
|
|
egui::Key::Comma => Self::Comma, egui::Key::Minus => Self::Minus,
|
|
egui::Key::Equals => Self::Equals, egui::Key::Plus => Self::Plus,
|
|
egui::Key::OpenBracket => Self::BracketLeft, egui::Key::CloseBracket => Self::BracketRight,
|
|
egui::Key::Semicolon => Self::Semicolon, egui::Key::Quote => Self::Quote,
|
|
egui::Key::Period => Self::Period, egui::Key::Slash => Self::Slash,
|
|
egui::Key::Backtick => Self::Backtick,
|
|
egui::Key::Space => Self::Space, egui::Key::Escape => Self::Escape,
|
|
egui::Key::Enter => Self::Enter, egui::Key::Tab => Self::Tab,
|
|
egui::Key::Backspace => Self::Backspace, egui::Key::Delete => Self::Delete,
|
|
egui::Key::Home => Self::Home, egui::Key::End => Self::End,
|
|
egui::Key::PageUp => Self::PageUp, egui::Key::PageDown => Self::PageDown,
|
|
_ => return None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Shortcut {
|
|
pub const fn new(key: ShortcutKey, ctrl: bool, shift: bool, alt: bool) -> Self {
|
|
Self { key, ctrl, shift, alt }
|
|
}
|
|
|
|
/// Short hint string suitable for tool tooltips (e.g. "F", "Ctrl+S").
|
|
pub fn hint_text(&self) -> String {
|
|
let mut parts: Vec<&str> = Vec::new();
|
|
if self.ctrl { parts.push("Ctrl"); }
|
|
if self.shift { parts.push("Shift"); }
|
|
if self.alt { parts.push("Alt"); }
|
|
parts.push(self.key.display_name());
|
|
parts.join("+")
|
|
}
|
|
|
|
/// 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::B => Code::KeyB,
|
|
ShortcutKey::C => Code::KeyC,
|
|
ShortcutKey::D => Code::KeyD,
|
|
ShortcutKey::E => Code::KeyE,
|
|
ShortcutKey::F => Code::KeyF,
|
|
ShortcutKey::G => Code::KeyG,
|
|
ShortcutKey::H => Code::KeyH,
|
|
ShortcutKey::I => Code::KeyI,
|
|
ShortcutKey::J => Code::KeyJ,
|
|
ShortcutKey::K => Code::KeyK,
|
|
ShortcutKey::L => Code::KeyL,
|
|
ShortcutKey::M => Code::KeyM,
|
|
ShortcutKey::N => Code::KeyN,
|
|
ShortcutKey::O => Code::KeyO,
|
|
ShortcutKey::P => Code::KeyP,
|
|
ShortcutKey::Q => Code::KeyQ,
|
|
ShortcutKey::R => Code::KeyR,
|
|
ShortcutKey::S => Code::KeyS,
|
|
ShortcutKey::T => Code::KeyT,
|
|
ShortcutKey::U => Code::KeyU,
|
|
ShortcutKey::V => Code::KeyV,
|
|
ShortcutKey::W => Code::KeyW,
|
|
ShortcutKey::X => Code::KeyX,
|
|
ShortcutKey::Y => Code::KeyY,
|
|
ShortcutKey::Z => Code::KeyZ,
|
|
ShortcutKey::Num0 => Code::Digit0,
|
|
ShortcutKey::Num1 => Code::Digit1,
|
|
ShortcutKey::Num2 => Code::Digit2,
|
|
ShortcutKey::Num3 => Code::Digit3,
|
|
ShortcutKey::Num4 => Code::Digit4,
|
|
ShortcutKey::Num5 => Code::Digit5,
|
|
ShortcutKey::Num6 => Code::Digit6,
|
|
ShortcutKey::Num7 => Code::Digit7,
|
|
ShortcutKey::Num8 => Code::Digit8,
|
|
ShortcutKey::Num9 => Code::Digit9,
|
|
ShortcutKey::F1 => Code::F1,
|
|
ShortcutKey::F2 => Code::F2,
|
|
ShortcutKey::F3 => Code::F3,
|
|
ShortcutKey::F4 => Code::F4,
|
|
ShortcutKey::F5 => Code::F5,
|
|
ShortcutKey::F6 => Code::F6,
|
|
ShortcutKey::F7 => Code::F7,
|
|
ShortcutKey::F8 => Code::F8,
|
|
ShortcutKey::F9 => Code::F9,
|
|
ShortcutKey::F10 => Code::F10,
|
|
ShortcutKey::F11 => Code::F11,
|
|
ShortcutKey::F12 => Code::F12,
|
|
ShortcutKey::ArrowUp => Code::ArrowUp,
|
|
ShortcutKey::ArrowDown => Code::ArrowDown,
|
|
ShortcutKey::ArrowLeft => Code::ArrowLeft,
|
|
ShortcutKey::ArrowRight => Code::ArrowRight,
|
|
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::Semicolon => Code::Semicolon,
|
|
ShortcutKey::Quote => Code::Quote,
|
|
ShortcutKey::Period => Code::Period,
|
|
ShortcutKey::Slash => Code::Slash,
|
|
ShortcutKey::Backtick => Code::Backquote,
|
|
ShortcutKey::Space => Code::Space,
|
|
ShortcutKey::Escape => Code::Escape,
|
|
ShortcutKey::Enter => Code::Enter,
|
|
ShortcutKey::Tab => Code::Tab,
|
|
ShortcutKey::Backspace => Code::Backspace,
|
|
ShortcutKey::Delete => Code::Delete,
|
|
ShortcutKey::Home => Code::Home,
|
|
ShortcutKey::End => Code::End,
|
|
ShortcutKey::PageUp => Code::PageUp,
|
|
ShortcutKey::PageDown => Code::PageDown,
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
input.key_pressed(self.key.to_egui_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,
|
|
AddRasterLayer,
|
|
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_RASTER_LAYER: Self = Self { label: "Add Raster Layer", action: MenuAction::AddRasterLayer, 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::Item(&Self::ADD_RASTER_LAYER),
|
|
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.
|
|
/// If a KeymapManager is provided, uses remapped bindings; otherwise falls back to static defaults.
|
|
pub fn check_shortcuts(input: &egui::InputState, keymap: Option<&crate::keymap::KeymapManager>) -> Option<MenuAction> {
|
|
if let Some(km) = keymap {
|
|
// Check all menu actions through the keymap
|
|
for def in MenuItemDef::all_with_shortcuts() {
|
|
if let Ok(app_action) = crate::keymap::AppAction::try_from(def.action) {
|
|
if km.action_pressed(app_action, input) {
|
|
return Some(def.action);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
} else {
|
|
for def in MenuItemDef::all_with_shortcuts() {
|
|
if let Some(shortcut) = &def.shortcut {
|
|
if shortcut.matches_egui_input(input) {
|
|
return Some(def.action);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Measure the minimum width needed for a menu's contents.
|
|
/// Accounts for label text + gap + shortcut text + padding.
|
|
fn measure_menu_width(ui: &egui::Ui, children: &[MenuDef], keymap: Option<&crate::keymap::KeymapManager>) -> f32 {
|
|
let label_font = egui::FontId::proportional(14.0);
|
|
let shortcut_font = egui::FontId::proportional(12.0);
|
|
let gap = 24.0; // space between label and shortcut
|
|
let padding = 16.0; // left + right padding
|
|
|
|
let mut max_width: f32 = 0.0;
|
|
for child in children {
|
|
match child {
|
|
MenuDef::Item(item_def) => {
|
|
let label_width = ui.fonts_mut(|f| f.layout_no_wrap(item_def.label.to_string(), label_font.clone(), egui::Color32::WHITE).size().x);
|
|
let effective_shortcut = if let Some(km) = keymap {
|
|
if let Ok(app_action) = crate::keymap::AppAction::try_from(item_def.action) {
|
|
km.get(app_action)
|
|
} else {
|
|
item_def.shortcut
|
|
}
|
|
} else {
|
|
item_def.shortcut
|
|
};
|
|
let shortcut_width = if let Some(shortcut) = &effective_shortcut {
|
|
let text = Self::format_shortcut(shortcut);
|
|
ui.fonts_mut(|f| f.layout_no_wrap(text, shortcut_font.clone(), egui::Color32::WHITE).size().x) + gap
|
|
} else {
|
|
0.0
|
|
};
|
|
max_width = max_width.max(label_width + shortcut_width);
|
|
}
|
|
MenuDef::Submenu { label, .. } => {
|
|
let label_width = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), label_font.clone(), egui::Color32::WHITE).size().x);
|
|
max_width = max_width.max(label_width + 20.0); // extra space for submenu arrow
|
|
}
|
|
MenuDef::Separator => {}
|
|
}
|
|
}
|
|
max_width + padding
|
|
}
|
|
|
|
/// 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],
|
|
keymap: Option<&crate::keymap::KeymapManager>,
|
|
layout_names: &[String],
|
|
current_layout_index: usize,
|
|
) -> Option<MenuAction> {
|
|
let mut action = None;
|
|
let ctx = ui.ctx().clone();
|
|
let menus = MenuItemDef::menu_structure();
|
|
|
|
egui::MenuBar::new().ui(ui, |ui| {
|
|
// Phase 1: render all top-level buttons and collect responses.
|
|
// For non-submenu items (separators, bare actions), render them inline.
|
|
let mut button_entries: Vec<(egui::Response, egui::Id, &MenuDef)> = Vec::new();
|
|
for menu_def in menus {
|
|
if let MenuDef::Submenu { label, .. } = menu_def {
|
|
let response = ui.button(*label);
|
|
let popup_id = egui::Popup::default_response_id(&response);
|
|
button_entries.push((response, popup_id, menu_def));
|
|
} else if let Some(a) = self.render_menu_def(ui, menu_def, recent_files, keymap, layout_names, current_layout_index) {
|
|
action = Some(a);
|
|
}
|
|
}
|
|
|
|
// Phase 2: hover-to-switch between top-level menus.
|
|
// If one of our menu popups is open and the user hovers a different button, switch.
|
|
let any_ours_open = button_entries.iter().any(|(_, pid, _)| egui::Popup::is_id_open(&ctx, *pid));
|
|
if any_ours_open {
|
|
for (response, popup_id, _) in &button_entries {
|
|
if response.hovered() && !egui::Popup::is_id_open(&ctx, *popup_id) {
|
|
// open_id closes all other popups and opens this one
|
|
egui::Popup::open_id(&ctx, *popup_id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 3: show popups via standard Popup::menu.
|
|
// Popup::menu sets UiKind::Menu, Frame::popup, menu_style, and MenuState::mark_shown,
|
|
// so SubMenuButton works correctly for nested submenus.
|
|
for (response, _, menu_def) in button_entries {
|
|
if let MenuDef::Submenu { children, .. } = menu_def {
|
|
let popup_result = egui::Popup::menu(&response).show(|ui| {
|
|
let min_width = Self::measure_menu_width(ui, children, keymap);
|
|
ui.set_width(min_width);
|
|
let mut a = None;
|
|
for child in *children {
|
|
if let Some(result) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) {
|
|
a = Some(result);
|
|
ui.close();
|
|
}
|
|
}
|
|
a
|
|
});
|
|
if let Some(r) = popup_result {
|
|
if let Some(a) = r.inner {
|
|
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],
|
|
keymap: Option<&crate::keymap::KeymapManager>,
|
|
layout_names: &[String],
|
|
current_layout_index: usize,
|
|
) -> Option<MenuAction> {
|
|
match def {
|
|
MenuDef::Item(item_def) => {
|
|
if Self::render_menu_item(ui, item_def, keymap) {
|
|
Some(item_def.action)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
MenuDef::Separator => {
|
|
ui.separator();
|
|
None
|
|
}
|
|
MenuDef::Submenu { label, children } => {
|
|
let (_, popup) = egui::containers::menu::SubMenuButton::new(*label)
|
|
.ui(ui, |ui| {
|
|
if *label == "Open Recent" {
|
|
let mut action = None;
|
|
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();
|
|
}
|
|
}
|
|
if !recent_files.is_empty() {
|
|
ui.separator();
|
|
}
|
|
if ui.button("Clear Recent Files").clicked() {
|
|
action = Some(MenuAction::ClearRecentFiles);
|
|
ui.close();
|
|
}
|
|
action
|
|
} else if *label == "Layout" {
|
|
let mut action = None;
|
|
for child in *children {
|
|
if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) {
|
|
action = Some(a);
|
|
ui.close();
|
|
}
|
|
}
|
|
if !layout_names.is_empty() {
|
|
ui.separator();
|
|
for (index, name) in layout_names.iter().enumerate() {
|
|
let entry = if index == current_layout_index {
|
|
format!("* {}", name)
|
|
} else {
|
|
name.clone()
|
|
};
|
|
if ui.button(entry).clicked() {
|
|
action = Some(MenuAction::SwitchLayout(index));
|
|
ui.close();
|
|
}
|
|
}
|
|
}
|
|
action
|
|
} else {
|
|
let mut action = None;
|
|
for child in *children {
|
|
if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) {
|
|
action = Some(a);
|
|
ui.close();
|
|
}
|
|
}
|
|
action
|
|
}
|
|
});
|
|
popup.and_then(|r| r.inner)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render a single menu item with label and shortcut
|
|
fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef, keymap: Option<&crate::keymap::KeymapManager>) -> bool {
|
|
// Look up shortcut from keymap if available, otherwise use static default
|
|
let effective_shortcut = if let Some(km) = keymap {
|
|
if let Ok(app_action) = crate::keymap::AppAction::try_from(def.action) {
|
|
km.get(app_action)
|
|
} else {
|
|
def.shortcut
|
|
}
|
|
} else {
|
|
def.shortcut
|
|
};
|
|
let shortcut_text = if let Some(shortcut) = &effective_shortcut {
|
|
Self::format_shortcut(shortcut)
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
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")
|
|
pub 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");
|
|
}
|
|
|
|
parts.push(shortcut.key.display_name());
|
|
|
|
parts.join("+")
|
|
}
|
|
|
|
/// Update native menu accelerator labels to match the current keymap
|
|
pub fn apply_keybindings(&self, keymap: &crate::keymap::KeymapManager) {
|
|
for (item, menu_action) in &self.items {
|
|
if let Ok(app_action) = crate::keymap::AppAction::try_from(*menu_action) {
|
|
let accelerator = keymap.get(app_action)
|
|
.map(|s| s.to_muda_accelerator());
|
|
let _ = item.set_accelerator(accelerator);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|