make keyboard shortcuts configurable

This commit is contained in:
Skyler Lehmkuhl 2026-02-25 07:36:53 -05:00
parent 353aec3513
commit 1cc7029321
9 changed files with 1166 additions and 101 deletions

View File

@ -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(),
}
}
}

View File

@ -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<MenuAction> 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<AppAction> for MenuAction {
type Error = ();
fn try_from(action: AppAction) -> Result<Self, ()> {
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<MenuAction> 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<Self, ()> {
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<AppAction, Option<Shortcut>> {
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<AppAction, Option<Shortcut>>,
}
impl KeybindingConfig {
/// Compute effective bindings by merging defaults with overrides
pub fn effective_bindings(&self) -> HashMap<AppAction, Option<Shortcut>> {
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<AppAction, Option<Shortcut>>,
/// Reverse lookup: shortcut -> list of actions (for conflict detection)
#[allow(dead_code)]
reverse: HashMap<Shortcut, Vec<AppAction>>,
}
impl KeymapManager {
/// Build from a KeybindingConfig
pub fn new(config: &KeybindingConfig) -> Self {
let bindings = config.effective_bindings();
let mut reverse: HashMap<Shortcut, Vec<AppAction>> = 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<Shortcut> {
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<AppAction>> = 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<Shortcut>) {
// 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 }
}
}

View File

@ -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<std::path::PathBuf>,
/// 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<FileCommand>,
@ -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<std::sync::atomic::AtomicU32>,
/// 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,

View File

@ -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<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 {
@ -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,9 +740,20 @@ impl MenuSystem {
None
}
/// Check keyboard shortcuts from egui input and return the action
/// This works cross-platform and complements native menus
pub fn check_shortcuts(input: &egui::InputState) -> Option<MenuAction> {
/// 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) {
@ -606,14 +763,15 @@ impl MenuSystem {
}
None
}
}
/// Render egui menu bar from the same menu structure (for Linux/Windows)
pub fn render_egui_menu_bar(&self, ui: &mut egui::Ui, recent_files: &[std::path::PathBuf]) -> Option<MenuAction> {
pub fn render_egui_menu_bar(&self, ui: &mut egui::Ui, recent_files: &[std::path::PathBuf], keymap: Option<&crate::keymap::KeymapManager>) -> Option<MenuAction> {
let mut action = None;
egui::MenuBar::new().ui(ui, |ui| {
for menu_def in MenuItemDef::menu_structure() {
if let Some(a) = self.render_menu_def(ui, menu_def, recent_files) {
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<MenuAction> {
fn render_menu_def(&self, ui: &mut egui::Ui, def: &MenuDef, recent_files: &[std::path::PathBuf], keymap: Option<&crate::keymap::KeymapManager>) -> Option<MenuAction> {
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>) {

View File

@ -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,

View File

@ -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()

View File

@ -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);

View File

@ -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;

View File

@ -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<String>,
// --- Shortcuts tab state ---
/// Working copy of keybindings (for live editing before save)
working_keybindings: HashMap<AppAction, Option<Shortcut>>,
/// Which action is currently being rebound (waiting for key press)
rebinding: Option<AppAction>,
/// 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<KeymapManager>,
}
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,7 +170,16 @@ impl PreferencesDialog {
ui.add_space(8.0);
}
// Scrollable area for preferences sections
// 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| {
@ -151,6 +193,11 @@ impl PreferencesDialog {
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> = 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<Shortcut, Vec<AppAction>>,
) {
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<Shortcut, Vec<AppAction>> {
// Group by (shortcut, conflict_scope)
let mut by_scope: HashMap<(&str, Shortcut), Vec<AppAction>> = 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<Shortcut, Vec<AppAction>> = 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),
})
}
}