From 1cc702932149035d1cb457fd1fec322aec574994 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 25 Feb 2026 07:36:53 -0500 Subject: [PATCH] make keyboard shortcuts configurable --- .../lightningbeam-editor/src/config.rs | 6 + .../lightningbeam-editor/src/keymap.rs | 615 ++++++++++++++++++ .../lightningbeam-editor/src/main.rs | 80 ++- .../lightningbeam-editor/src/menu.rs | 277 ++++++-- .../lightningbeam-editor/src/panes/mod.rs | 2 + .../src/panes/node_graph/mod.rs | 6 +- .../src/panes/piano_roll.rs | 2 +- .../lightningbeam-editor/src/panes/stage.rs | 2 +- .../src/preferences/dialog.rs | 277 +++++++- 9 files changed, 1166 insertions(+), 101 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/keymap.rs diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index 412f7f0..a1efa3b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use crate::keymap::KeybindingConfig; /// Application configuration (persistent) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -52,6 +53,10 @@ pub struct AppConfig { /// Theme mode ("light", "dark", or "system") #[serde(default = "defaults::theme_mode")] pub theme_mode: String, + + /// Custom keyboard shortcut overrides (sparse — only non-default bindings stored) + #[serde(default)] + pub keybindings: KeybindingConfig, } impl Default for AppConfig { @@ -69,6 +74,7 @@ impl Default for AppConfig { debug: defaults::debug(), waveform_stereo: defaults::waveform_stereo(), theme_mode: defaults::theme_mode(), + keybindings: KeybindingConfig::default(), } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs new file mode 100644 index 0000000..3273d20 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs @@ -0,0 +1,615 @@ +//! Remappable keyboard shortcuts system +//! +//! Provides a unified `AppAction` enum for all bindable actions, a `KeymapManager` +//! for runtime shortcut lookup, and `KeybindingConfig` for persistent storage of +//! user overrides. + +use std::collections::HashMap; +use eframe::egui; +use serde::{Serialize, Deserialize}; +use crate::menu::{MenuAction, Shortcut, ShortcutKey}; + +/// Unified enum of every bindable action in the application. +/// +/// Excludes virtual piano keys (keyboard-layout-dependent, not user-preference shortcuts). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AppAction { + // === File menu === + NewFile, + NewWindow, + Save, + SaveAs, + OpenFile, + 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, + DeleteLayer, + ToggleLayerVisibility, + + // === Timeline menu === + NewKeyframe, + NewBlankKeyframe, + DeleteFrame, + DuplicateKeyframe, + AddKeyframeAtPlayhead, + AddMotionTween, + AddShapeTween, + ReturnToStart, + Play, + + // === View menu === + ZoomIn, + ZoomOut, + ActualSize, + RecenterView, + NextLayout, + PreviousLayout, + + // === Help === + About, + + // === macOS / Window === + Settings, + CloseWindow, + + // === Tool shortcuts (no modifiers) === + ToolSelect, + ToolDraw, + ToolTransform, + ToolRectangle, + ToolEllipse, + ToolPaintBucket, + ToolEyedropper, + ToolLine, + ToolPolygon, + ToolBezierEdit, + ToolText, + ToolRegionSelect, + + // === Global shortcuts === + TogglePlayPause, + CancelAction, + ToggleDebugOverlay, + #[cfg(debug_assertions)] + ToggleTestMode, + + // === Pane-local shortcuts === + PianoRollDelete, + StageDelete, + NodeGraphGroup, + NodeGraphUngroup, + NodeGraphRename, +} + +impl AppAction { + /// Category name for grouping in the preferences UI + pub fn category(&self) -> &'static str { + match self { + Self::NewFile | Self::NewWindow | Self::Save | Self::SaveAs | + Self::OpenFile | Self::Revert | Self::Import | Self::ImportToLibrary | + Self::Export | Self::Quit => "File", + + Self::Undo | Self::Redo | Self::Cut | Self::Copy | Self::Paste | + Self::Delete | Self::SelectAll | Self::SelectNone | Self::Preferences => "Edit", + + Self::Group | Self::ConvertToMovieClip | Self::SendToBack | + Self::BringToFront | Self::SplitClip | Self::DuplicateClip => "Modify", + + Self::AddLayer | Self::AddVideoLayer | Self::AddAudioTrack | + Self::AddMidiTrack | Self::AddTestClip | Self::DeleteLayer | + Self::ToggleLayerVisibility => "Layer", + + Self::NewKeyframe | Self::NewBlankKeyframe | Self::DeleteFrame | + Self::DuplicateKeyframe | Self::AddKeyframeAtPlayhead | + Self::AddMotionTween | Self::AddShapeTween | + Self::ReturnToStart | Self::Play => "Timeline", + + Self::ZoomIn | Self::ZoomOut | Self::ActualSize | Self::RecenterView | + Self::NextLayout | Self::PreviousLayout => "View", + + Self::About => "Help", + Self::Settings | Self::CloseWindow => "Window", + + Self::ToolSelect | Self::ToolDraw | Self::ToolTransform | + Self::ToolRectangle | Self::ToolEllipse | Self::ToolPaintBucket | + Self::ToolEyedropper | Self::ToolLine | Self::ToolPolygon | + Self::ToolBezierEdit | Self::ToolText | Self::ToolRegionSelect => "Tools", + + Self::TogglePlayPause | Self::CancelAction | + Self::ToggleDebugOverlay => "Global", + #[cfg(debug_assertions)] + Self::ToggleTestMode => "Global", + + Self::PianoRollDelete | Self::StageDelete | + Self::NodeGraphGroup | Self::NodeGraphUngroup | + Self::NodeGraphRename => "Pane", + } + } + + /// Conflict scope: actions can only conflict with other actions in the same scope. + /// Pane-local actions each get their own scope (they're isolated to their pane), + /// everything else shares the "global" scope. + pub fn conflict_scope(&self) -> &'static str { + match self { + Self::PianoRollDelete => "pane:piano_roll", + Self::StageDelete => "pane:stage", + Self::NodeGraphGroup | Self::NodeGraphUngroup | + Self::NodeGraphRename => "pane:node_graph", + _ => "global", + } + } + + /// Human-readable display name for the preferences UI + pub fn display_name(&self) -> &'static str { + match self { + Self::NewFile => "New File", + Self::NewWindow => "New Window", + Self::Save => "Save", + Self::SaveAs => "Save As", + Self::OpenFile => "Open File", + Self::Revert => "Revert", + Self::Import => "Import", + Self::ImportToLibrary => "Import to Library", + Self::Export => "Export", + Self::Quit => "Quit", + Self::Undo => "Undo", + Self::Redo => "Redo", + Self::Cut => "Cut", + Self::Copy => "Copy", + Self::Paste => "Paste", + Self::Delete => "Delete", + Self::SelectAll => "Select All", + Self::SelectNone => "Select None", + Self::Preferences => "Preferences", + Self::Group => "Group", + Self::ConvertToMovieClip => "Convert to Movie Clip", + Self::SendToBack => "Send to Back", + Self::BringToFront => "Bring to Front", + Self::SplitClip => "Split Clip", + Self::DuplicateClip => "Duplicate Clip", + Self::AddLayer => "Add Layer", + Self::AddVideoLayer => "Add Video Layer", + Self::AddAudioTrack => "Add Audio Track", + Self::AddMidiTrack => "Add MIDI Track", + Self::AddTestClip => "Add Test Clip", + Self::DeleteLayer => "Delete Layer", + Self::ToggleLayerVisibility => "Toggle Layer Visibility", + Self::NewKeyframe => "New Keyframe", + Self::NewBlankKeyframe => "New Blank Keyframe", + Self::DeleteFrame => "Delete Frame", + Self::DuplicateKeyframe => "Duplicate Keyframe", + Self::AddKeyframeAtPlayhead => "Add Keyframe at Playhead", + Self::AddMotionTween => "Add Motion Tween", + Self::AddShapeTween => "Add Shape Tween", + Self::ReturnToStart => "Return to Start", + Self::Play => "Play", + Self::ZoomIn => "Zoom In", + Self::ZoomOut => "Zoom Out", + Self::ActualSize => "Actual Size", + Self::RecenterView => "Recenter View", + Self::NextLayout => "Next Layout", + Self::PreviousLayout => "Previous Layout", + Self::About => "About", + Self::Settings => "Settings", + Self::CloseWindow => "Close Window", + Self::ToolSelect => "Select Tool", + Self::ToolDraw => "Draw Tool", + Self::ToolTransform => "Transform Tool", + Self::ToolRectangle => "Rectangle Tool", + Self::ToolEllipse => "Ellipse Tool", + Self::ToolPaintBucket => "Paint Bucket Tool", + Self::ToolEyedropper => "Eyedropper Tool", + Self::ToolLine => "Line Tool", + Self::ToolPolygon => "Polygon Tool", + Self::ToolBezierEdit => "Bezier Edit Tool", + Self::ToolText => "Text Tool", + Self::ToolRegionSelect => "Region Select Tool", + Self::TogglePlayPause => "Toggle Play/Pause", + Self::CancelAction => "Cancel / Escape", + Self::ToggleDebugOverlay => "Toggle Debug Overlay", + #[cfg(debug_assertions)] + Self::ToggleTestMode => "Toggle Test Mode", + Self::PianoRollDelete => "Piano Roll: Delete", + Self::StageDelete => "Stage: Delete", + Self::NodeGraphGroup => "Node Graph: Group", + Self::NodeGraphUngroup => "Node Graph: Ungroup", + Self::NodeGraphRename => "Node Graph: Rename", + } + } + + /// All action variants (for iteration) + pub fn all() -> &'static [AppAction] { + &[ + Self::NewFile, Self::NewWindow, Self::Save, Self::SaveAs, + Self::OpenFile, Self::Revert, Self::Import, Self::ImportToLibrary, + Self::Export, Self::Quit, + Self::Undo, Self::Redo, Self::Cut, Self::Copy, Self::Paste, + Self::Delete, Self::SelectAll, Self::SelectNone, Self::Preferences, + Self::Group, Self::ConvertToMovieClip, Self::SendToBack, + Self::BringToFront, Self::SplitClip, Self::DuplicateClip, + Self::AddLayer, Self::AddVideoLayer, Self::AddAudioTrack, + Self::AddMidiTrack, Self::AddTestClip, Self::DeleteLayer, + Self::ToggleLayerVisibility, + Self::NewKeyframe, Self::NewBlankKeyframe, Self::DeleteFrame, + Self::DuplicateKeyframe, Self::AddKeyframeAtPlayhead, + Self::AddMotionTween, Self::AddShapeTween, + Self::ReturnToStart, Self::Play, + Self::ZoomIn, Self::ZoomOut, Self::ActualSize, Self::RecenterView, + Self::NextLayout, Self::PreviousLayout, + Self::About, Self::Settings, Self::CloseWindow, + Self::ToolSelect, Self::ToolDraw, Self::ToolTransform, + Self::ToolRectangle, Self::ToolEllipse, Self::ToolPaintBucket, + Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon, + Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect, + Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay, + #[cfg(debug_assertions)] + Self::ToggleTestMode, + Self::PianoRollDelete, Self::StageDelete, + Self::NodeGraphGroup, Self::NodeGraphUngroup, Self::NodeGraphRename, + ] + } +} + +// === Conversions between MenuAction and AppAction === + +impl From for AppAction { + fn from(action: MenuAction) -> Self { + match action { + MenuAction::NewFile => Self::NewFile, + MenuAction::NewWindow => Self::NewWindow, + MenuAction::Save => Self::Save, + MenuAction::SaveAs => Self::SaveAs, + MenuAction::OpenFile => Self::OpenFile, + MenuAction::OpenRecent(_) => Self::OpenFile, // not directly mappable + MenuAction::ClearRecentFiles => Self::OpenFile, // not directly mappable + MenuAction::Revert => Self::Revert, + MenuAction::Import => Self::Import, + MenuAction::ImportToLibrary => Self::ImportToLibrary, + MenuAction::Export => Self::Export, + MenuAction::Quit => Self::Quit, + MenuAction::Undo => Self::Undo, + MenuAction::Redo => Self::Redo, + MenuAction::Cut => Self::Cut, + MenuAction::Copy => Self::Copy, + MenuAction::Paste => Self::Paste, + MenuAction::Delete => Self::Delete, + MenuAction::SelectAll => Self::SelectAll, + MenuAction::SelectNone => Self::SelectNone, + MenuAction::Preferences => Self::Preferences, + MenuAction::Group => Self::Group, + MenuAction::ConvertToMovieClip => Self::ConvertToMovieClip, + MenuAction::SendToBack => Self::SendToBack, + MenuAction::BringToFront => Self::BringToFront, + MenuAction::SplitClip => Self::SplitClip, + MenuAction::DuplicateClip => Self::DuplicateClip, + MenuAction::AddLayer => Self::AddLayer, + MenuAction::AddVideoLayer => Self::AddVideoLayer, + MenuAction::AddAudioTrack => Self::AddAudioTrack, + MenuAction::AddMidiTrack => Self::AddMidiTrack, + MenuAction::AddTestClip => Self::AddTestClip, + MenuAction::DeleteLayer => Self::DeleteLayer, + MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility, + MenuAction::NewKeyframe => Self::NewKeyframe, + MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe, + MenuAction::DeleteFrame => Self::DeleteFrame, + MenuAction::DuplicateKeyframe => Self::DuplicateKeyframe, + MenuAction::AddKeyframeAtPlayhead => Self::AddKeyframeAtPlayhead, + MenuAction::AddMotionTween => Self::AddMotionTween, + MenuAction::AddShapeTween => Self::AddShapeTween, + MenuAction::ReturnToStart => Self::ReturnToStart, + MenuAction::Play => Self::Play, + MenuAction::ZoomIn => Self::ZoomIn, + MenuAction::ZoomOut => Self::ZoomOut, + MenuAction::ActualSize => Self::ActualSize, + MenuAction::RecenterView => Self::RecenterView, + MenuAction::NextLayout => Self::NextLayout, + MenuAction::PreviousLayout => Self::PreviousLayout, + MenuAction::SwitchLayout(_) => Self::NextLayout, // not directly mappable + MenuAction::About => Self::About, + MenuAction::Settings => Self::Settings, + MenuAction::CloseWindow => Self::CloseWindow, + } + } +} + +impl TryFrom for MenuAction { + type Error = (); + fn try_from(action: AppAction) -> Result { + Ok(match action { + AppAction::NewFile => MenuAction::NewFile, + AppAction::NewWindow => MenuAction::NewWindow, + AppAction::Save => MenuAction::Save, + AppAction::SaveAs => MenuAction::SaveAs, + AppAction::OpenFile => MenuAction::OpenFile, + AppAction::Revert => MenuAction::Revert, + AppAction::Import => MenuAction::Import, + AppAction::ImportToLibrary => MenuAction::ImportToLibrary, + AppAction::Export => MenuAction::Export, + AppAction::Quit => MenuAction::Quit, + AppAction::Undo => MenuAction::Undo, + AppAction::Redo => MenuAction::Redo, + AppAction::Cut => MenuAction::Cut, + AppAction::Copy => MenuAction::Copy, + AppAction::Paste => MenuAction::Paste, + AppAction::Delete => MenuAction::Delete, + AppAction::SelectAll => MenuAction::SelectAll, + AppAction::SelectNone => MenuAction::SelectNone, + AppAction::Preferences => MenuAction::Preferences, + AppAction::Group => MenuAction::Group, + AppAction::ConvertToMovieClip => MenuAction::ConvertToMovieClip, + AppAction::SendToBack => MenuAction::SendToBack, + AppAction::BringToFront => MenuAction::BringToFront, + AppAction::SplitClip => MenuAction::SplitClip, + AppAction::DuplicateClip => MenuAction::DuplicateClip, + AppAction::AddLayer => MenuAction::AddLayer, + AppAction::AddVideoLayer => MenuAction::AddVideoLayer, + AppAction::AddAudioTrack => MenuAction::AddAudioTrack, + AppAction::AddMidiTrack => MenuAction::AddMidiTrack, + AppAction::AddTestClip => MenuAction::AddTestClip, + AppAction::DeleteLayer => MenuAction::DeleteLayer, + AppAction::ToggleLayerVisibility => MenuAction::ToggleLayerVisibility, + AppAction::NewKeyframe => MenuAction::NewKeyframe, + AppAction::NewBlankKeyframe => MenuAction::NewBlankKeyframe, + AppAction::DeleteFrame => MenuAction::DeleteFrame, + AppAction::DuplicateKeyframe => MenuAction::DuplicateKeyframe, + AppAction::AddKeyframeAtPlayhead => MenuAction::AddKeyframeAtPlayhead, + AppAction::AddMotionTween => MenuAction::AddMotionTween, + AppAction::AddShapeTween => MenuAction::AddShapeTween, + AppAction::ReturnToStart => MenuAction::ReturnToStart, + AppAction::Play => MenuAction::Play, + AppAction::ZoomIn => MenuAction::ZoomIn, + AppAction::ZoomOut => MenuAction::ZoomOut, + AppAction::ActualSize => MenuAction::ActualSize, + AppAction::RecenterView => MenuAction::RecenterView, + AppAction::NextLayout => MenuAction::NextLayout, + AppAction::PreviousLayout => MenuAction::PreviousLayout, + AppAction::About => MenuAction::About, + AppAction::Settings => MenuAction::Settings, + AppAction::CloseWindow => MenuAction::CloseWindow, + // Non-menu actions + _ => return Err(()), + }) + } +} + +// Also need TryFrom for AppAction (used in menu.rs check_shortcuts) +impl AppAction { + /// Try to convert from a MenuAction (fails for OpenRecent/ClearRecentFiles/SwitchLayout) + pub fn try_from(action: MenuAction) -> Result { + match action { + MenuAction::OpenRecent(_) | MenuAction::ClearRecentFiles | MenuAction::SwitchLayout(_) => Err(()), + other => Ok(Self::from(other)), + } + } +} + +// === Default bindings === + +/// Build the complete default bindings map from the current hardcoded shortcuts +pub fn all_defaults() -> HashMap> { + use crate::menu::MenuItemDef; + + let mut defaults = HashMap::new(); + + // Menu action defaults (from MenuItemDef constants) + for def in MenuItemDef::all_with_shortcuts() { + if let Ok(app_action) = AppAction::try_from(def.action) { + defaults.insert(app_action, def.shortcut); + } + } + + // Also add menu items without shortcuts + let no_shortcut: &[AppAction] = &[ + AppAction::Revert, AppAction::Preferences, AppAction::ConvertToMovieClip, + AppAction::SendToBack, AppAction::BringToFront, + AppAction::AddVideoLayer, AppAction::AddAudioTrack, AppAction::AddMidiTrack, + AppAction::AddTestClip, AppAction::DeleteLayer, AppAction::ToggleLayerVisibility, + AppAction::NewBlankKeyframe, AppAction::DeleteFrame, AppAction::DuplicateKeyframe, + AppAction::AddKeyframeAtPlayhead, AppAction::AddMotionTween, AppAction::AddShapeTween, + AppAction::ReturnToStart, AppAction::Play, AppAction::RecenterView, AppAction::About, + ]; + for &action in no_shortcut { + defaults.entry(action).or_insert(None); + } + + // Tool shortcuts (bare keys, no modifiers) + let nc = false; + let ns = false; + let na = false; + defaults.insert(AppAction::ToolSelect, Some(Shortcut::new(ShortcutKey::V, nc, ns, na))); + defaults.insert(AppAction::ToolDraw, Some(Shortcut::new(ShortcutKey::P, nc, ns, na))); + defaults.insert(AppAction::ToolTransform, Some(Shortcut::new(ShortcutKey::Q, nc, ns, na))); + defaults.insert(AppAction::ToolRectangle, Some(Shortcut::new(ShortcutKey::R, nc, ns, na))); + defaults.insert(AppAction::ToolEllipse, Some(Shortcut::new(ShortcutKey::E, nc, ns, na))); + defaults.insert(AppAction::ToolPaintBucket, Some(Shortcut::new(ShortcutKey::B, nc, ns, na))); + defaults.insert(AppAction::ToolEyedropper, Some(Shortcut::new(ShortcutKey::I, nc, ns, na))); + defaults.insert(AppAction::ToolLine, Some(Shortcut::new(ShortcutKey::L, nc, ns, na))); + defaults.insert(AppAction::ToolPolygon, Some(Shortcut::new(ShortcutKey::G, nc, ns, na))); + defaults.insert(AppAction::ToolBezierEdit, Some(Shortcut::new(ShortcutKey::A, nc, ns, na))); + defaults.insert(AppAction::ToolText, Some(Shortcut::new(ShortcutKey::T, nc, ns, na))); + defaults.insert(AppAction::ToolRegionSelect, Some(Shortcut::new(ShortcutKey::S, nc, ns, na))); + + // Global shortcuts + defaults.insert(AppAction::TogglePlayPause, Some(Shortcut::new(ShortcutKey::Space, nc, ns, na))); + defaults.insert(AppAction::CancelAction, Some(Shortcut::new(ShortcutKey::Escape, nc, ns, na))); + defaults.insert(AppAction::ToggleDebugOverlay, Some(Shortcut::new(ShortcutKey::F3, nc, ns, na))); + #[cfg(debug_assertions)] + defaults.insert(AppAction::ToggleTestMode, Some(Shortcut::new(ShortcutKey::F5, nc, ns, na))); + + // Pane-local shortcuts + defaults.insert(AppAction::PianoRollDelete, Some(Shortcut::new(ShortcutKey::Delete, nc, ns, na))); + defaults.insert(AppAction::StageDelete, Some(Shortcut::new(ShortcutKey::Delete, nc, ns, na))); + defaults.insert(AppAction::NodeGraphGroup, Some(Shortcut::new(ShortcutKey::G, true, ns, na))); + defaults.insert(AppAction::NodeGraphUngroup, Some(Shortcut::new(ShortcutKey::G, true, true, na))); + defaults.insert(AppAction::NodeGraphRename, Some(Shortcut::new(ShortcutKey::F2, nc, ns, na))); + + defaults +} + +// === KeybindingConfig (persisted in AppConfig) === + +/// Sparse override map: only stores non-default bindings. +/// `None` value means "unbound" (user explicitly cleared the binding). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct KeybindingConfig { + #[serde(default)] + pub overrides: HashMap>, +} + +impl KeybindingConfig { + /// Compute effective bindings by merging defaults with overrides + pub fn effective_bindings(&self) -> HashMap> { + let mut bindings = all_defaults(); + for (action, shortcut) in &self.overrides { + bindings.insert(*action, *shortcut); + } + bindings + } + + /// Reset all overrides (revert to defaults) + #[allow(dead_code)] + pub fn reset(&mut self) { + self.overrides.clear(); + } +} + +// === KeymapManager (runtime lookup) === + +/// Runtime shortcut lookup table, built from KeybindingConfig. +/// Consulted everywhere shortcuts are checked. +pub struct KeymapManager { + /// action -> shortcut (None means unbound) + bindings: HashMap>, + /// Reverse lookup: shortcut -> list of actions (for conflict detection) + #[allow(dead_code)] + reverse: HashMap>, +} + +impl KeymapManager { + /// Build from a KeybindingConfig + pub fn new(config: &KeybindingConfig) -> Self { + let bindings = config.effective_bindings(); + let mut reverse: HashMap> = HashMap::new(); + for (&action, shortcut) in &bindings { + if let Some(s) = shortcut { + reverse.entry(*s).or_default().push(action); + } + } + Self { bindings, reverse } + } + + /// Get the shortcut bound to an action (None = unbound) + pub fn get(&self, action: AppAction) -> Option { + self.bindings.get(&action).copied().flatten() + } + + /// Check if the shortcut for an action was pressed this frame + pub fn action_pressed(&self, action: AppAction, input: &egui::InputState) -> bool { + if let Some(shortcut) = self.get(action) { + shortcut.matches_egui_input(input) + } else { + false + } + } + + /// Check if the action was pressed, also accepting Backspace as alias for Delete + pub fn action_pressed_with_backspace(&self, action: AppAction, input: &egui::InputState) -> bool { + if self.action_pressed(action, input) { + return true; + } + // Also check Backspace as a secondary trigger for delete-like actions + if let Some(shortcut) = self.get(action) { + if shortcut.key == ShortcutKey::Delete { + let backspace_shortcut = Shortcut::new(ShortcutKey::Backspace, shortcut.ctrl, shortcut.shift, shortcut.alt); + return backspace_shortcut.matches_egui_input(input); + } + } + false + } + + /// Find all conflicts (two+ actions in the same scope sharing the same shortcut). + /// Pane-local actions are scoped to their pane and can't conflict across panes. + #[allow(dead_code)] + pub fn conflicts(&self) -> Vec<(AppAction, AppAction, Shortcut)> { + // Group by (scope, shortcut) + let mut by_scope: HashMap<(&str, Shortcut), Vec> = HashMap::new(); + for (shortcut, actions) in &self.reverse { + for &action in actions { + by_scope.entry((action.conflict_scope(), *shortcut)).or_default().push(action); + } + } + let mut conflicts = Vec::new(); + for ((_, shortcut), actions) in &by_scope { + if actions.len() > 1 { + for i in 0..actions.len() { + for j in (i + 1)..actions.len() { + conflicts.push((actions[i], actions[j], *shortcut)); + } + } + } + } + conflicts + } + + /// Set a binding for live editing (used in preferences dialog). + /// Does NOT persist — call `to_config()` to get the persistable form. + #[allow(dead_code)] + pub fn set_binding(&mut self, action: AppAction, shortcut: Option) { + // Remove old reverse entry + if let Some(old) = self.bindings.get(&action).copied().flatten() { + if let Some(actions) = self.reverse.get_mut(&old) { + actions.retain(|a| *a != action); + if actions.is_empty() { + self.reverse.remove(&old); + } + } + } + // Set new binding + self.bindings.insert(action, shortcut); + if let Some(s) = shortcut { + self.reverse.entry(s).or_default().push(action); + } + } + + /// Convert current state to a sparse config (only non-default entries) + #[allow(dead_code)] + pub fn to_config(&self) -> KeybindingConfig { + let defaults = all_defaults(); + let mut overrides = HashMap::new(); + for (&action, &shortcut) in &self.bindings { + let default = defaults.get(&action).copied().flatten(); + if shortcut != default { + overrides.insert(action, shortcut); + } + } + KeybindingConfig { overrides } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 09876f8..45a783e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -27,6 +27,9 @@ mod cqt_gpu; mod config; use config::AppConfig; +mod keymap; +use keymap::KeymapManager; + mod default_instrument; mod export; @@ -820,6 +823,8 @@ struct EditorApp { current_file_path: Option, /// Application configuration (recent files, etc.) config: AppConfig, + /// Remappable keyboard shortcut manager + keymap: KeymapManager, /// File operations worker command sender file_command_tx: std::sync::mpsc::Sender, @@ -1042,6 +1047,7 @@ impl EditorApp { waveform_gpu_dirty: HashSet::new(), recording_mirror_rx, current_file_path: None, // No file loaded initially + keymap: KeymapManager::new(&config.keybindings), config, file_command_tx, file_operation: None, // No file operation in progress initially @@ -4571,6 +4577,14 @@ impl eframe::App for EditorApp { if result.buffer_size_changed { println!("⚠️ Audio buffer size will be applied on next app restart"); } + // Apply new keybindings if changed + if let Some(new_keymap) = result.new_keymap { + self.keymap = new_keymap; + // Update native menu accelerator labels + if let Some(menu_system) = &self.menu_system { + menu_system.apply_keybindings(&self.keymap); + } + } } // Render video frames incrementally (if video export in progress) @@ -4698,7 +4712,7 @@ impl eframe::App for EditorApp { egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { if let Some(menu_system) = &self.menu_system { let recent_files = self.config.get_recent_files(); - if let Some(action) = menu_system.render_egui_menu_bar(ui, &recent_files) { + if let Some(action) = menu_system.render_egui_menu_bar(ui, &recent_files, Some(&self.keymap)) { self.handle_menu_action(action); } } @@ -4859,6 +4873,7 @@ impl eframe::App for EditorApp { region_select_mode: &mut self.region_select_mode, pending_graph_loads: &self.pending_graph_loads, clipboard_consumed: &mut clipboard_consumed, + keymap: &self.keymap, #[cfg(debug_assertions)] test_mode: &mut self.test_mode, #[cfg(debug_assertions)] @@ -5013,7 +5028,7 @@ impl eframe::App for EditorApp { let wants_keyboard = ctx.wants_keyboard_input(); // Space bar toggles play/pause (only when no text input is focused) - if !wants_keyboard && ctx.input(|i| i.key_pressed(egui::Key::Space)) { + if !wants_keyboard && ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::TogglePlayPause, i)) { self.is_playing = !self.is_playing; if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); @@ -5049,41 +5064,38 @@ impl eframe::App for EditorApp { // Check menu shortcuts that use modifiers (Cmd+S, etc.) - allow even when typing // But skip shortcuts without modifiers when keyboard input is claimed (e.g., virtual piano) - if let Some(action) = MenuSystem::check_shortcuts(i) { + if let Some(action) = MenuSystem::check_shortcuts(i, Some(&self.keymap)) { // Only trigger if keyboard isn't claimed OR the shortcut uses modifiers if !wants_keyboard || i.modifiers.ctrl || i.modifiers.command || i.modifiers.alt || i.modifiers.shift { self.handle_menu_action(action); } } - // Check tool shortcuts (only if no modifiers are held AND no text input is focused) - if !wants_keyboard && !i.modifiers.ctrl && !i.modifiers.shift && !i.modifiers.alt && !i.modifiers.command { + // Check tool shortcuts (only if no text input is focused; + // modifier guard is encoded in the bindings themselves — default tool bindings have no modifiers) + if !wants_keyboard { use lightningbeam_core::tool::Tool; + use crate::keymap::AppAction; - if i.key_pressed(egui::Key::V) { - self.selected_tool = Tool::Select; - } else if i.key_pressed(egui::Key::P) { - self.selected_tool = Tool::Draw; - } else if i.key_pressed(egui::Key::Q) { - self.selected_tool = Tool::Transform; - } else if i.key_pressed(egui::Key::R) { - self.selected_tool = Tool::Rectangle; - } else if i.key_pressed(egui::Key::E) { - self.selected_tool = Tool::Ellipse; - } else if i.key_pressed(egui::Key::B) { - self.selected_tool = Tool::PaintBucket; - } else if i.key_pressed(egui::Key::I) { - self.selected_tool = Tool::Eyedropper; - } else if i.key_pressed(egui::Key::L) { - self.selected_tool = Tool::Line; - } else if i.key_pressed(egui::Key::G) { - self.selected_tool = Tool::Polygon; - } else if i.key_pressed(egui::Key::A) { - self.selected_tool = Tool::BezierEdit; - } else if i.key_pressed(egui::Key::T) { - self.selected_tool = Tool::Text; - } else if i.key_pressed(egui::Key::S) { - self.selected_tool = Tool::RegionSelect; + let tool_map: &[(AppAction, Tool)] = &[ + (AppAction::ToolSelect, Tool::Select), + (AppAction::ToolDraw, Tool::Draw), + (AppAction::ToolTransform, Tool::Transform), + (AppAction::ToolRectangle, Tool::Rectangle), + (AppAction::ToolEllipse, Tool::Ellipse), + (AppAction::ToolPaintBucket, Tool::PaintBucket), + (AppAction::ToolEyedropper, Tool::Eyedropper), + (AppAction::ToolLine, Tool::Line), + (AppAction::ToolPolygon, Tool::Polygon), + (AppAction::ToolBezierEdit, Tool::BezierEdit), + (AppAction::ToolText, Tool::Text), + (AppAction::ToolRegionSelect, Tool::RegionSelect), + ]; + for &(action, tool) in tool_map { + if self.keymap.action_pressed(action, i) { + self.selected_tool = tool; + break; + } } } }); @@ -5106,7 +5118,7 @@ impl eframe::App for EditorApp { } // Escape key: revert uncommitted region selection - if !wants_keyboard && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if !wants_keyboard && ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::CancelAction, i)) { if self.region_selection.is_some() { Self::revert_region_selection( &mut self.region_selection, @@ -5117,13 +5129,13 @@ impl eframe::App for EditorApp { } // F3 debug overlay toggle (works even when text input is active) - if ctx.input(|i| i.key_pressed(egui::Key::F3)) { + if ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::ToggleDebugOverlay, i)) { self.debug_overlay_visible = !self.debug_overlay_visible; } // F5 test mode toggle (debug builds only) #[cfg(debug_assertions)] - if ctx.input(|i| i.key_pressed(egui::Key::F5)) { + if ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::ToggleTestMode, i)) { self.test_mode.active = !self.test_mode.active; if self.test_mode.active { self.test_mode.refresh_test_list(); @@ -5242,6 +5254,8 @@ struct RenderContext<'a> { pending_graph_loads: &'a std::sync::Arc, /// Set by panes when they handle Ctrl+C/X/V internally clipboard_consumed: &'a mut bool, + /// Remappable keyboard shortcut manager + keymap: &'a KeymapManager, /// Test mode state for event recording (debug builds only) #[cfg(debug_assertions)] test_mode: &'a mut test_mode::TestModeState, @@ -5737,6 +5751,7 @@ fn render_pane( region_select_mode: ctx.region_select_mode, pending_graph_loads: ctx.pending_graph_loads, clipboard_consumed: ctx.clipboard_consumed, + keymap: ctx.keymap, editing_clip_id: ctx.editing_clip_id, editing_instance_id: ctx.editing_instance_id, editing_parent_layer_id: ctx.editing_parent_layer_id, @@ -5824,6 +5839,7 @@ fn render_pane( region_select_mode: ctx.region_select_mode, pending_graph_loads: ctx.pending_graph_loads, clipboard_consumed: ctx.clipboard_consumed, + keymap: ctx.keymap, editing_clip_id: ctx.editing_clip_id, editing_instance_id: ctx.editing_instance_id, editing_parent_layer_id: ctx.editing_parent_layer_id, diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index e2dd80d..fdba78a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -13,7 +13,7 @@ use muda::{ }; /// Keyboard shortcut definition -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct Shortcut { pub key: ShortcutKey, pub ctrl: bool, @@ -22,19 +22,67 @@ pub struct Shortcut { } /// Keys that can be used in shortcuts -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum ShortcutKey { // Letters - A, C, D, E, G, I, K, L, N, O, Q, S, V, W, X, Z, - // Numbers - Num0, + 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 - Delete, + Space, Escape, Enter, Tab, Backspace, Delete, + Home, End, PageUp, PageDown, +} + +impl ShortcutKey { + /// Try to convert an egui Key to a ShortcutKey + pub fn from_egui_key(key: egui::Key) -> Option { + 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 { @@ -60,29 +108,78 @@ impl Shortcut { 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) @@ -104,29 +201,78 @@ impl Shortcut { // Check key let key = match self.key { ShortcutKey::A => egui::Key::A, + ShortcutKey::B => egui::Key::B, ShortcutKey::C => egui::Key::C, ShortcutKey::D => egui::Key::D, ShortcutKey::E => egui::Key::E, + ShortcutKey::F => egui::Key::F, ShortcutKey::G => egui::Key::G, + ShortcutKey::H => egui::Key::H, ShortcutKey::I => egui::Key::I, + ShortcutKey::J => egui::Key::J, ShortcutKey::K => egui::Key::K, ShortcutKey::L => egui::Key::L, + ShortcutKey::M => egui::Key::M, ShortcutKey::N => egui::Key::N, ShortcutKey::O => egui::Key::O, + ShortcutKey::P => egui::Key::P, ShortcutKey::Q => egui::Key::Q, + ShortcutKey::R => egui::Key::R, ShortcutKey::S => egui::Key::S, + ShortcutKey::T => egui::Key::T, + ShortcutKey::U => egui::Key::U, ShortcutKey::V => egui::Key::V, ShortcutKey::W => egui::Key::W, ShortcutKey::X => egui::Key::X, + ShortcutKey::Y => egui::Key::Y, ShortcutKey::Z => egui::Key::Z, ShortcutKey::Num0 => egui::Key::Num0, + ShortcutKey::Num1 => egui::Key::Num1, + ShortcutKey::Num2 => egui::Key::Num2, + ShortcutKey::Num3 => egui::Key::Num3, + ShortcutKey::Num4 => egui::Key::Num4, + ShortcutKey::Num5 => egui::Key::Num5, + ShortcutKey::Num6 => egui::Key::Num6, + ShortcutKey::Num7 => egui::Key::Num7, + ShortcutKey::Num8 => egui::Key::Num8, + ShortcutKey::Num9 => egui::Key::Num9, + ShortcutKey::F1 => egui::Key::F1, + ShortcutKey::F2 => egui::Key::F2, + ShortcutKey::F3 => egui::Key::F3, + ShortcutKey::F4 => egui::Key::F4, + ShortcutKey::F5 => egui::Key::F5, + ShortcutKey::F6 => egui::Key::F6, + ShortcutKey::F7 => egui::Key::F7, + ShortcutKey::F8 => egui::Key::F8, + ShortcutKey::F9 => egui::Key::F9, + ShortcutKey::F10 => egui::Key::F10, + ShortcutKey::F11 => egui::Key::F11, + ShortcutKey::F12 => egui::Key::F12, + ShortcutKey::ArrowUp => egui::Key::ArrowUp, + ShortcutKey::ArrowDown => egui::Key::ArrowDown, + ShortcutKey::ArrowLeft => egui::Key::ArrowLeft, + ShortcutKey::ArrowRight => egui::Key::ArrowRight, 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::Semicolon => egui::Key::Semicolon, + ShortcutKey::Quote => egui::Key::Quote, + ShortcutKey::Period => egui::Key::Period, + ShortcutKey::Slash => egui::Key::Slash, + ShortcutKey::Backtick => egui::Key::Backtick, + ShortcutKey::Space => egui::Key::Space, + ShortcutKey::Escape => egui::Key::Escape, + ShortcutKey::Enter => egui::Key::Enter, + ShortcutKey::Tab => egui::Key::Tab, + ShortcutKey::Backspace => egui::Key::Backspace, ShortcutKey::Delete => egui::Key::Delete, + ShortcutKey::Home => egui::Key::Home, + ShortcutKey::End => egui::Key::End, + ShortcutKey::PageUp => egui::Key::PageUp, + ShortcutKey::PageDown => egui::Key::PageDown, }; input.key_pressed(key) @@ -594,26 +740,38 @@ 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 { - for def in MenuItemDef::all_with_shortcuts() { - if let Some(shortcut) = &def.shortcut { - if shortcut.matches_egui_input(input) { - return Some(def.action); + /// 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 { + 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 } - 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 { + pub fn render_egui_menu_bar(&self, ui: &mut egui::Ui, recent_files: &[std::path::PathBuf], keymap: Option<&crate::keymap::KeymapManager>) -> Option { let mut action = None; egui::MenuBar::new().ui(ui, |ui| { for menu_def in MenuItemDef::menu_structure() { - if let Some(a) = self.render_menu_def(ui, menu_def, recent_files) { + if let Some(a) = self.render_menu_def(ui, menu_def, recent_files, keymap) { action = Some(a); } } @@ -623,10 +781,10 @@ impl MenuSystem { } /// Recursively render a MenuDef as egui UI - fn render_menu_def(&self, ui: &mut egui::Ui, def: &MenuDef, recent_files: &[std::path::PathBuf]) -> Option { + fn render_menu_def(&self, ui: &mut egui::Ui, def: &MenuDef, recent_files: &[std::path::PathBuf], keymap: Option<&crate::keymap::KeymapManager>) -> Option { match def { MenuDef::Item(item_def) => { - if Self::render_menu_item(ui, item_def) { + if Self::render_menu_item(ui, item_def, keymap) { Some(item_def.action) } else { None @@ -666,7 +824,7 @@ impl MenuSystem { } else { // Normal submenu rendering for child in *children { - if let Some(a) = self.render_menu_def(ui, child, recent_files) { + if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap) { action = Some(a); ui.close(); } @@ -679,8 +837,18 @@ impl MenuSystem { } /// 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 { + 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() @@ -733,7 +901,7 @@ impl MenuSystem { } /// Format shortcut for display (e.g., "Ctrl+S") - fn format_shortcut(shortcut: &Shortcut) -> String { + pub fn format_shortcut(shortcut: &Shortcut) -> String { let mut parts = Vec::new(); if shortcut.ctrl { @@ -747,36 +915,53 @@ impl MenuSystem { } 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", + ShortcutKey::A => "A", ShortcutKey::B => "B", ShortcutKey::C => "C", + ShortcutKey::D => "D", ShortcutKey::E => "E", ShortcutKey::F => "F", + ShortcutKey::G => "G", ShortcutKey::H => "H", ShortcutKey::I => "I", + ShortcutKey::J => "J", ShortcutKey::K => "K", ShortcutKey::L => "L", + ShortcutKey::M => "M", ShortcutKey::N => "N", ShortcutKey::O => "O", + ShortcutKey::P => "P", ShortcutKey::Q => "Q", ShortcutKey::R => "R", + ShortcutKey::S => "S", ShortcutKey::T => "T", ShortcutKey::U => "U", + ShortcutKey::V => "V", ShortcutKey::W => "W", ShortcutKey::X => "X", + ShortcutKey::Y => "Y", ShortcutKey::Z => "Z", + ShortcutKey::Num0 => "0", ShortcutKey::Num1 => "1", ShortcutKey::Num2 => "2", + ShortcutKey::Num3 => "3", ShortcutKey::Num4 => "4", ShortcutKey::Num5 => "5", + ShortcutKey::Num6 => "6", ShortcutKey::Num7 => "7", ShortcutKey::Num8 => "8", + ShortcutKey::Num9 => "9", + ShortcutKey::F1 => "F1", ShortcutKey::F2 => "F2", ShortcutKey::F3 => "F3", + ShortcutKey::F4 => "F4", ShortcutKey::F5 => "F5", ShortcutKey::F6 => "F6", + ShortcutKey::F7 => "F7", ShortcutKey::F8 => "F8", ShortcutKey::F9 => "F9", + ShortcutKey::F10 => "F10", ShortcutKey::F11 => "F11", ShortcutKey::F12 => "F12", + ShortcutKey::ArrowUp => "Up", ShortcutKey::ArrowDown => "Down", + ShortcutKey::ArrowLeft => "Left", ShortcutKey::ArrowRight => "Right", + ShortcutKey::Comma => ",", ShortcutKey::Minus => "-", + ShortcutKey::Equals => "=", ShortcutKey::Plus => "+", + ShortcutKey::BracketLeft => "[", ShortcutKey::BracketRight => "]", + ShortcutKey::Semicolon => ";", ShortcutKey::Quote => "'", + ShortcutKey::Period => ".", ShortcutKey::Slash => "/", + ShortcutKey::Backtick => "`", + ShortcutKey::Space => "Space", ShortcutKey::Escape => "Esc", + ShortcutKey::Enter => "Enter", ShortcutKey::Tab => "Tab", + ShortcutKey::Backspace => "Backspace", ShortcutKey::Delete => "Del", + ShortcutKey::Home => "Home", ShortcutKey::End => "End", + ShortcutKey::PageUp => "PgUp", ShortcutKey::PageDown => "PgDn", }; parts.push(key_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>) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index e6d1a6b..b9cd303 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -246,6 +246,8 @@ pub struct SharedPaneState<'a> { /// Set by panes (e.g. piano roll) when they handle Ctrl+C/X/V internally, /// so main.rs skips its own clipboard handling for the current frame pub clipboard_consumed: &'a mut bool, + /// Remappable keyboard shortcut manager + pub keymap: &'a crate::keymap::KeymapManager, /// Test mode state for event recording (debug builds only) #[cfg(debug_assertions)] pub test_mode: &'a mut crate::test_mode::TestModeState, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index 590fbb1..a1f4f55 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -3087,7 +3087,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane { // Handle pane-local keyboard shortcuts (only when pointer is over this pane) if ui.rect_contains_pointer(rect) { let ctrl_g = ui.input(|i| { - i.key_pressed(egui::Key::G) && (i.modifiers.ctrl || i.modifiers.command) + shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphGroup, i) }); if ctrl_g && !self.state.selected_nodes.is_empty() { self.group_selected_nodes(shared); @@ -3095,7 +3095,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane { // Ctrl+Shift+G to ungroup let ctrl_shift_g = ui.input(|i| { - i.key_pressed(egui::Key::G) && (i.modifiers.ctrl || i.modifiers.command) && i.modifiers.shift + shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphUngroup, i) }); if ctrl_shift_g { // Ungroup any selected group placeholders @@ -3108,7 +3108,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } // F2 to rename selected group - let f2 = ui.input(|i| i.key_pressed(egui::Key::F2)); + let f2 = ui.input(|i| shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphRename, i)); if f2 && self.renaming_group.is_none() { // Find the first selected group placeholder if let Some(group_id) = self.state.selected_nodes.iter() diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 5d82e31..c6af4da 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -725,7 +725,7 @@ impl PianoRollPane { } // Delete key - let delete_pressed = ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)); + let delete_pressed = ui.input(|i| shared.keymap.action_pressed_with_backspace(crate::keymap::AppAction::PianoRollDelete, i)); if delete_pressed && !self.selected_note_indices.is_empty() { if let Some(clip_id) = self.selected_clip_id { self.delete_selected_notes(clip_id, shared, clip_data); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 4ad8361..e7f557d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -6033,7 +6033,7 @@ impl StagePane { { self.replay_override = None; } // Delete/Backspace: remove selected DCEL elements - if ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { + if ui.input(|i| shared.keymap.action_pressed_with_backspace(crate::keymap::AppAction::StageDelete, i)) { if shared.selection.has_dcel_selection() { if let Some(active_layer_id) = *shared.active_layer_id { let time = *shared.playback_time; diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs index 4196c19..970cd0e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -1,16 +1,30 @@ //! Preferences dialog UI //! -//! Provides a user interface for configuring application preferences +//! Provides a user interface for configuring application preferences, +//! including a Keyboard Shortcuts tab with click-to-rebind support. +use std::collections::HashMap; use eframe::egui; use crate::config::AppConfig; +use crate::keymap::{self, AppAction, KeymapManager}; +use crate::menu::{MenuSystem, Shortcut, ShortcutKey}; use crate::theme::{Theme, ThemeMode}; +/// Which tab is selected in the preferences dialog +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PreferencesTab { + General, + Shortcuts, +} + /// Preferences dialog state pub struct PreferencesDialog { /// Is the dialog open? pub open: bool, + /// Currently selected tab + tab: PreferencesTab, + /// Working copy of preferences (allows cancel to discard changes) working_prefs: PreferencesState, @@ -19,6 +33,16 @@ pub struct PreferencesDialog { /// Error message (if validation fails) error_message: Option, + + // --- Shortcuts tab state --- + /// Working copy of keybindings (for live editing before save) + working_keybindings: HashMap>, + + /// Which action is currently being rebound (waiting for key press) + rebinding: Option, + + /// Search/filter text for shortcuts list + shortcut_filter: String, } /// Editable preferences state (working copy) @@ -74,19 +98,24 @@ impl Default for PreferencesState { } /// Result returned when preferences are saved -#[derive(Debug, Clone)] pub struct PreferencesSaveResult { /// Whether audio buffer size changed (requires restart) pub buffer_size_changed: bool, + /// New keymap manager if keybindings changed (caller must replace their keymap and call apply_keybindings) + pub new_keymap: Option, } impl Default for PreferencesDialog { fn default() -> Self { Self { open: false, + tab: PreferencesTab::General, working_prefs: PreferencesState::default(), original_buffer_size: 256, error_message: None, + working_keybindings: HashMap::new(), + rebinding: None, + shortcut_filter: String::new(), } } } @@ -98,12 +127,16 @@ impl PreferencesDialog { self.working_prefs = PreferencesState::from((config, theme)); self.original_buffer_size = config.audio_buffer_size; self.error_message = None; + self.working_keybindings = config.keybindings.effective_bindings(); + self.rebinding = None; + self.shortcut_filter.clear(); } /// Close the dialog pub fn close(&mut self) { self.open = false; self.error_message = None; + self.rebinding = None; } /// Render the preferences dialog @@ -129,7 +162,7 @@ impl PreferencesDialog { .collapsible(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .show(ctx, |ui| { - ui.set_width(500.0); + ui.set_width(550.0); // Error message if let Some(error) = &self.error_message { @@ -137,20 +170,34 @@ impl PreferencesDialog { ui.add_space(8.0); } - // Scrollable area for preferences sections - egui::ScrollArea::vertical() - .max_height(400.0) - .show(ui, |ui| { - self.render_general_section(ui); - ui.add_space(8.0); - self.render_audio_section(ui); - ui.add_space(8.0); - self.render_appearance_section(ui); - ui.add_space(8.0); - self.render_startup_section(ui); - ui.add_space(8.0); - self.render_advanced_section(ui); - }); + // Tab bar + ui.horizontal(|ui| { + ui.selectable_value(&mut self.tab, PreferencesTab::General, "General"); + ui.selectable_value(&mut self.tab, PreferencesTab::Shortcuts, "Keyboard Shortcuts"); + }); + ui.separator(); + + // Tab content + match self.tab { + PreferencesTab::General => { + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + self.render_general_section(ui); + ui.add_space(8.0); + self.render_audio_section(ui); + ui.add_space(8.0); + self.render_appearance_section(ui); + ui.add_space(8.0); + self.render_startup_section(ui); + ui.add_space(8.0); + self.render_advanced_section(ui); + }); + } + PreferencesTab::Shortcuts => { + self.render_shortcuts_tab(ui); + } + } ui.add_space(16.0); @@ -187,6 +234,184 @@ impl PreferencesDialog { None } + fn render_shortcuts_tab(&mut self, ui: &mut egui::Ui) { + // Capture key events for rebinding BEFORE rendering the rest + if let Some(rebind_action) = self.rebinding { + // Intercept key presses for rebinding + let captured = ui.input(|i| { + for event in &i.events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = event { + // Escape clears the binding + if *key == egui::Key::Escape && !modifiers.ctrl && !modifiers.shift && !modifiers.alt { + return Some(None); // Clear binding + } + // Any other key: set as new binding + if let Some(shortcut_key) = ShortcutKey::from_egui_key(*key) { + return Some(Some(Shortcut::new( + shortcut_key, + modifiers.ctrl || modifiers.command, + modifiers.shift, + modifiers.alt, + ))); + } + } + } + None + }); + + if let Some(new_binding) = captured { + self.working_keybindings.insert(rebind_action, new_binding); + self.rebinding = None; + } + } + + // Search/filter + ui.horizontal(|ui| { + ui.label("Filter:"); + ui.text_edit_singleline(&mut self.shortcut_filter); + }); + ui.add_space(4.0); + + // Conflict detection + let conflicts = self.detect_conflicts(); + if !conflicts.is_empty() { + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::from_rgb(255, 180, 50), + format!("{} conflict(s) detected", conflicts.len())); + }); + ui.add_space(4.0); + } + + // Scrollable list of actions grouped by category + egui::ScrollArea::vertical() + .max_height(350.0) + .show(ui, |ui| { + let filter_lower = self.shortcut_filter.to_lowercase(); + + // Collect categories in display order + let category_order = [ + "File", "Edit", "Modify", "Layer", "Timeline", "View", + "Help", "Window", "Tools", "Global", "Pane", + ]; + + for category in &category_order { + let actions_in_category: Vec = AppAction::all().iter() + .filter(|a| a.category() == *category) + .filter(|a| { + if filter_lower.is_empty() { + true + } else { + a.display_name().to_lowercase().contains(&filter_lower) + || a.category().to_lowercase().contains(&filter_lower) + } + }) + .copied() + .collect(); + + if actions_in_category.is_empty() { + continue; + } + + egui::CollapsingHeader::new(*category) + .default_open(!filter_lower.is_empty() || *category == "Tools" || *category == "Global") + .show(ui, |ui| { + for action in &actions_in_category { + self.render_shortcut_row(ui, *action, &conflicts); + } + }); + } + }); + } + + fn render_shortcut_row( + &mut self, + ui: &mut egui::Ui, + action: AppAction, + conflicts: &HashMap>, + ) { + let binding = self.working_keybindings.get(&action).copied().flatten(); + let is_rebinding = self.rebinding == Some(action); + let has_conflict = binding + .as_ref() + .and_then(|s| conflicts.get(s)) + .map(|actions| actions.len() > 1) + .unwrap_or(false); + + ui.horizontal(|ui| { + // Action name (fixed width) + ui.add_sized([200.0, 20.0], egui::Label::new(action.display_name())); + + // Binding button (click to rebind) + let button_text = if is_rebinding { + "Press a key...".to_string() + } else if let Some(s) = &binding { + MenuSystem::format_shortcut(s) + } else { + "None".to_string() + }; + + let button_color = if is_rebinding { + egui::Color32::from_rgb(100, 150, 255) + } else if has_conflict { + egui::Color32::from_rgb(255, 180, 50) + } else { + ui.visuals().widgets.inactive.text_color() + }; + + let response = ui.add_sized( + [140.0, 20.0], + egui::Button::new(egui::RichText::new(&button_text).color(button_color)), + ); + + if response.clicked() && !is_rebinding { + self.rebinding = Some(action); + } + + // Show conflict tooltip + if has_conflict { + if let Some(s) = &binding { + if let Some(conflicting) = conflicts.get(s) { + let others: Vec<&str> = conflicting.iter() + .filter(|a| **a != action) + .map(|a| a.display_name()) + .collect(); + response.on_hover_text(format!("Conflicts with: {}", others.join(", "))); + } + } + } + + // Clear button + if ui.small_button("x").clicked() { + self.working_keybindings.insert(action, None); + if self.rebinding == Some(action) { + self.rebinding = None; + } + } + }); + } + + /// Detect all shortcut conflicts (shortcut -> list of actions sharing it). + /// Only actions within the same conflict scope can conflict — pane-local actions + /// are isolated to their pane and never conflict with each other or global actions. + fn detect_conflicts(&self) -> HashMap> { + // Group by (shortcut, conflict_scope) + let mut by_scope: HashMap<(&str, Shortcut), Vec> = HashMap::new(); + for (&action, &shortcut) in &self.working_keybindings { + if let Some(s) = shortcut { + by_scope.entry((action.conflict_scope(), s)).or_default().push(action); + } + } + + // Flatten into shortcut -> conflicting actions (only where there are actual conflicts) + let mut result: HashMap> = HashMap::new(); + for ((_, shortcut), actions) in by_scope { + if actions.len() > 1 { + result.entry(shortcut).or_default().extend(actions); + } + } + result + } + fn render_general_section(&mut self, ui: &mut egui::Ui) { egui::CollapsingHeader::new("General") .default_open(true) @@ -284,7 +509,7 @@ impl PreferencesDialog { }); }); - ui.label("⚠ Requires app restart to take effect"); + ui.label("Requires app restart to take effect"); }); } @@ -347,6 +572,8 @@ impl PreferencesDialog { fn reset_to_defaults(&mut self) { self.working_prefs = PreferencesState::default(); + self.working_keybindings = keymap::all_defaults(); + self.rebinding = None; self.error_message = None; } @@ -378,6 +605,18 @@ impl PreferencesDialog { // Check if buffer size changed let buffer_size_changed = self.working_prefs.audio_buffer_size != self.original_buffer_size; + // Build new keymap from working keybindings to compute sparse overrides + let defaults = keymap::all_defaults(); + let mut overrides = HashMap::new(); + for (&action, &shortcut) in &self.working_keybindings { + let default = defaults.get(&action).copied().flatten(); + if shortcut != default { + overrides.insert(action, shortcut); + } + } + let keybinding_config = keymap::KeybindingConfig { overrides }; + let new_keymap = KeymapManager::new(&keybinding_config); + // Apply changes to config config.bpm = self.working_prefs.bpm; config.framerate = self.working_prefs.framerate; @@ -390,6 +629,7 @@ impl PreferencesDialog { config.debug = self.working_prefs.debug; config.waveform_stereo = self.working_prefs.waveform_stereo; config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); + config.keybindings = keybinding_config; // Apply theme immediately theme.set_mode(self.working_prefs.theme_mode); @@ -402,6 +642,7 @@ impl PreferencesDialog { Some(PreferencesSaveResult { buffer_size_changed, + new_keymap: Some(new_keymap), }) } }