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 serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use crate::keymap::KeybindingConfig;
/// Application configuration (persistent) /// Application configuration (persistent)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -52,6 +53,10 @@ pub struct AppConfig {
/// Theme mode ("light", "dark", or "system") /// Theme mode ("light", "dark", or "system")
#[serde(default = "defaults::theme_mode")] #[serde(default = "defaults::theme_mode")]
pub theme_mode: String, pub theme_mode: String,
/// Custom keyboard shortcut overrides (sparse — only non-default bindings stored)
#[serde(default)]
pub keybindings: KeybindingConfig,
} }
impl Default for AppConfig { impl Default for AppConfig {
@ -69,6 +74,7 @@ impl Default for AppConfig {
debug: defaults::debug(), debug: defaults::debug(),
waveform_stereo: defaults::waveform_stereo(), waveform_stereo: defaults::waveform_stereo(),
theme_mode: defaults::theme_mode(), 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; mod config;
use config::AppConfig; use config::AppConfig;
mod keymap;
use keymap::KeymapManager;
mod default_instrument; mod default_instrument;
mod export; mod export;
@ -820,6 +823,8 @@ struct EditorApp {
current_file_path: Option<std::path::PathBuf>, current_file_path: Option<std::path::PathBuf>,
/// Application configuration (recent files, etc.) /// Application configuration (recent files, etc.)
config: AppConfig, config: AppConfig,
/// Remappable keyboard shortcut manager
keymap: KeymapManager,
/// File operations worker command sender /// File operations worker command sender
file_command_tx: std::sync::mpsc::Sender<FileCommand>, file_command_tx: std::sync::mpsc::Sender<FileCommand>,
@ -1042,6 +1047,7 @@ impl EditorApp {
waveform_gpu_dirty: HashSet::new(), waveform_gpu_dirty: HashSet::new(),
recording_mirror_rx, recording_mirror_rx,
current_file_path: None, // No file loaded initially current_file_path: None, // No file loaded initially
keymap: KeymapManager::new(&config.keybindings),
config, config,
file_command_tx, file_command_tx,
file_operation: None, // No file operation in progress initially file_operation: None, // No file operation in progress initially
@ -4571,6 +4577,14 @@ impl eframe::App for EditorApp {
if result.buffer_size_changed { if result.buffer_size_changed {
println!("⚠️ Audio buffer size will be applied on next app restart"); 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) // 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| { egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
if let Some(menu_system) = &self.menu_system { if let Some(menu_system) = &self.menu_system {
let recent_files = self.config.get_recent_files(); 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); self.handle_menu_action(action);
} }
} }
@ -4859,6 +4873,7 @@ impl eframe::App for EditorApp {
region_select_mode: &mut self.region_select_mode, region_select_mode: &mut self.region_select_mode,
pending_graph_loads: &self.pending_graph_loads, pending_graph_loads: &self.pending_graph_loads,
clipboard_consumed: &mut clipboard_consumed, clipboard_consumed: &mut clipboard_consumed,
keymap: &self.keymap,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
test_mode: &mut self.test_mode, test_mode: &mut self.test_mode,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -5013,7 +5028,7 @@ impl eframe::App for EditorApp {
let wants_keyboard = ctx.wants_keyboard_input(); let wants_keyboard = ctx.wants_keyboard_input();
// Space bar toggles play/pause (only when no text input is focused) // 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; self.is_playing = !self.is_playing;
if let Some(ref controller_arc) = self.audio_controller { if let Some(ref controller_arc) = self.audio_controller {
let mut controller = controller_arc.lock().unwrap(); 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 // 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) // 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 // 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 { if !wants_keyboard || i.modifiers.ctrl || i.modifiers.command || i.modifiers.alt || i.modifiers.shift {
self.handle_menu_action(action); self.handle_menu_action(action);
} }
} }
// Check tool shortcuts (only if no modifiers are held AND no text input is focused) // Check tool shortcuts (only if no text input is focused;
if !wants_keyboard && !i.modifiers.ctrl && !i.modifiers.shift && !i.modifiers.alt && !i.modifiers.command { // modifier guard is encoded in the bindings themselves — default tool bindings have no modifiers)
if !wants_keyboard {
use lightningbeam_core::tool::Tool; use lightningbeam_core::tool::Tool;
use crate::keymap::AppAction;
if i.key_pressed(egui::Key::V) { let tool_map: &[(AppAction, Tool)] = &[
self.selected_tool = Tool::Select; (AppAction::ToolSelect, Tool::Select),
} else if i.key_pressed(egui::Key::P) { (AppAction::ToolDraw, Tool::Draw),
self.selected_tool = Tool::Draw; (AppAction::ToolTransform, Tool::Transform),
} else if i.key_pressed(egui::Key::Q) { (AppAction::ToolRectangle, Tool::Rectangle),
self.selected_tool = Tool::Transform; (AppAction::ToolEllipse, Tool::Ellipse),
} else if i.key_pressed(egui::Key::R) { (AppAction::ToolPaintBucket, Tool::PaintBucket),
self.selected_tool = Tool::Rectangle; (AppAction::ToolEyedropper, Tool::Eyedropper),
} else if i.key_pressed(egui::Key::E) { (AppAction::ToolLine, Tool::Line),
self.selected_tool = Tool::Ellipse; (AppAction::ToolPolygon, Tool::Polygon),
} else if i.key_pressed(egui::Key::B) { (AppAction::ToolBezierEdit, Tool::BezierEdit),
self.selected_tool = Tool::PaintBucket; (AppAction::ToolText, Tool::Text),
} else if i.key_pressed(egui::Key::I) { (AppAction::ToolRegionSelect, Tool::RegionSelect),
self.selected_tool = Tool::Eyedropper; ];
} else if i.key_pressed(egui::Key::L) { for &(action, tool) in tool_map {
self.selected_tool = Tool::Line; if self.keymap.action_pressed(action, i) {
} else if i.key_pressed(egui::Key::G) { self.selected_tool = tool;
self.selected_tool = Tool::Polygon; break;
} 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;
} }
} }
}); });
@ -5106,7 +5118,7 @@ impl eframe::App for EditorApp {
} }
// Escape key: revert uncommitted region selection // 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() { if self.region_selection.is_some() {
Self::revert_region_selection( Self::revert_region_selection(
&mut self.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) // 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; self.debug_overlay_visible = !self.debug_overlay_visible;
} }
// F5 test mode toggle (debug builds only) // F5 test mode toggle (debug builds only)
#[cfg(debug_assertions)] #[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; self.test_mode.active = !self.test_mode.active;
if self.test_mode.active { if self.test_mode.active {
self.test_mode.refresh_test_list(); self.test_mode.refresh_test_list();
@ -5242,6 +5254,8 @@ struct RenderContext<'a> {
pending_graph_loads: &'a std::sync::Arc<std::sync::atomic::AtomicU32>, pending_graph_loads: &'a std::sync::Arc<std::sync::atomic::AtomicU32>,
/// Set by panes when they handle Ctrl+C/X/V internally /// Set by panes when they handle Ctrl+C/X/V internally
clipboard_consumed: &'a mut bool, clipboard_consumed: &'a mut bool,
/// Remappable keyboard shortcut manager
keymap: &'a KeymapManager,
/// Test mode state for event recording (debug builds only) /// Test mode state for event recording (debug builds only)
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
test_mode: &'a mut test_mode::TestModeState, test_mode: &'a mut test_mode::TestModeState,
@ -5737,6 +5751,7 @@ fn render_pane(
region_select_mode: ctx.region_select_mode, region_select_mode: ctx.region_select_mode,
pending_graph_loads: ctx.pending_graph_loads, pending_graph_loads: ctx.pending_graph_loads,
clipboard_consumed: ctx.clipboard_consumed, clipboard_consumed: ctx.clipboard_consumed,
keymap: ctx.keymap,
editing_clip_id: ctx.editing_clip_id, editing_clip_id: ctx.editing_clip_id,
editing_instance_id: ctx.editing_instance_id, editing_instance_id: ctx.editing_instance_id,
editing_parent_layer_id: ctx.editing_parent_layer_id, editing_parent_layer_id: ctx.editing_parent_layer_id,
@ -5824,6 +5839,7 @@ fn render_pane(
region_select_mode: ctx.region_select_mode, region_select_mode: ctx.region_select_mode,
pending_graph_loads: ctx.pending_graph_loads, pending_graph_loads: ctx.pending_graph_loads,
clipboard_consumed: ctx.clipboard_consumed, clipboard_consumed: ctx.clipboard_consumed,
keymap: ctx.keymap,
editing_clip_id: ctx.editing_clip_id, editing_clip_id: ctx.editing_clip_id,
editing_instance_id: ctx.editing_instance_id, editing_instance_id: ctx.editing_instance_id,
editing_parent_layer_id: ctx.editing_parent_layer_id, editing_parent_layer_id: ctx.editing_parent_layer_id,

View File

@ -13,7 +13,7 @@ use muda::{
}; };
/// Keyboard shortcut definition /// Keyboard shortcut definition
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Shortcut { pub struct Shortcut {
pub key: ShortcutKey, pub key: ShortcutKey,
pub ctrl: bool, pub ctrl: bool,
@ -22,19 +22,67 @@ pub struct Shortcut {
} }
/// Keys that can be used in shortcuts /// 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 { pub enum ShortcutKey {
// Letters // Letters
A, C, D, E, G, I, K, L, N, O, Q, S, V, W, X, Z, 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,
// Numbers // Digits
Num0, 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 // Symbols
Comma, Minus, Equals, Comma, Minus, Equals,
#[allow(dead_code)] // Completes keyboard mapping set #[allow(dead_code)] // Completes keyboard mapping set
Plus, Plus,
BracketLeft, BracketRight, BracketLeft, BracketRight,
Semicolon, Quote, Period, Slash, Backtick,
// Special // 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 { impl Shortcut {
@ -60,29 +108,78 @@ impl Shortcut {
let code = match self.key { let code = match self.key {
ShortcutKey::A => Code::KeyA, ShortcutKey::A => Code::KeyA,
ShortcutKey::B => Code::KeyB,
ShortcutKey::C => Code::KeyC, ShortcutKey::C => Code::KeyC,
ShortcutKey::D => Code::KeyD, ShortcutKey::D => Code::KeyD,
ShortcutKey::E => Code::KeyE, ShortcutKey::E => Code::KeyE,
ShortcutKey::F => Code::KeyF,
ShortcutKey::G => Code::KeyG, ShortcutKey::G => Code::KeyG,
ShortcutKey::H => Code::KeyH,
ShortcutKey::I => Code::KeyI, ShortcutKey::I => Code::KeyI,
ShortcutKey::J => Code::KeyJ,
ShortcutKey::K => Code::KeyK, ShortcutKey::K => Code::KeyK,
ShortcutKey::L => Code::KeyL, ShortcutKey::L => Code::KeyL,
ShortcutKey::M => Code::KeyM,
ShortcutKey::N => Code::KeyN, ShortcutKey::N => Code::KeyN,
ShortcutKey::O => Code::KeyO, ShortcutKey::O => Code::KeyO,
ShortcutKey::P => Code::KeyP,
ShortcutKey::Q => Code::KeyQ, ShortcutKey::Q => Code::KeyQ,
ShortcutKey::R => Code::KeyR,
ShortcutKey::S => Code::KeyS, ShortcutKey::S => Code::KeyS,
ShortcutKey::T => Code::KeyT,
ShortcutKey::U => Code::KeyU,
ShortcutKey::V => Code::KeyV, ShortcutKey::V => Code::KeyV,
ShortcutKey::W => Code::KeyW, ShortcutKey::W => Code::KeyW,
ShortcutKey::X => Code::KeyX, ShortcutKey::X => Code::KeyX,
ShortcutKey::Y => Code::KeyY,
ShortcutKey::Z => Code::KeyZ, ShortcutKey::Z => Code::KeyZ,
ShortcutKey::Num0 => Code::Digit0, 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::Comma => Code::Comma,
ShortcutKey::Minus => Code::Minus, ShortcutKey::Minus => Code::Minus,
ShortcutKey::Equals => Code::Equal, ShortcutKey::Equals => Code::Equal,
ShortcutKey::Plus => Code::Equal, // Same key as equals ShortcutKey::Plus => Code::Equal, // Same key as equals
ShortcutKey::BracketLeft => Code::BracketLeft, ShortcutKey::BracketLeft => Code::BracketLeft,
ShortcutKey::BracketRight => Code::BracketRight, 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::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) Accelerator::new(if modifiers.is_empty() { None } else { Some(modifiers) }, code)
@ -104,29 +201,78 @@ impl Shortcut {
// Check key // Check key
let key = match self.key { let key = match self.key {
ShortcutKey::A => egui::Key::A, ShortcutKey::A => egui::Key::A,
ShortcutKey::B => egui::Key::B,
ShortcutKey::C => egui::Key::C, ShortcutKey::C => egui::Key::C,
ShortcutKey::D => egui::Key::D, ShortcutKey::D => egui::Key::D,
ShortcutKey::E => egui::Key::E, ShortcutKey::E => egui::Key::E,
ShortcutKey::F => egui::Key::F,
ShortcutKey::G => egui::Key::G, ShortcutKey::G => egui::Key::G,
ShortcutKey::H => egui::Key::H,
ShortcutKey::I => egui::Key::I, ShortcutKey::I => egui::Key::I,
ShortcutKey::J => egui::Key::J,
ShortcutKey::K => egui::Key::K, ShortcutKey::K => egui::Key::K,
ShortcutKey::L => egui::Key::L, ShortcutKey::L => egui::Key::L,
ShortcutKey::M => egui::Key::M,
ShortcutKey::N => egui::Key::N, ShortcutKey::N => egui::Key::N,
ShortcutKey::O => egui::Key::O, ShortcutKey::O => egui::Key::O,
ShortcutKey::P => egui::Key::P,
ShortcutKey::Q => egui::Key::Q, ShortcutKey::Q => egui::Key::Q,
ShortcutKey::R => egui::Key::R,
ShortcutKey::S => egui::Key::S, ShortcutKey::S => egui::Key::S,
ShortcutKey::T => egui::Key::T,
ShortcutKey::U => egui::Key::U,
ShortcutKey::V => egui::Key::V, ShortcutKey::V => egui::Key::V,
ShortcutKey::W => egui::Key::W, ShortcutKey::W => egui::Key::W,
ShortcutKey::X => egui::Key::X, ShortcutKey::X => egui::Key::X,
ShortcutKey::Y => egui::Key::Y,
ShortcutKey::Z => egui::Key::Z, ShortcutKey::Z => egui::Key::Z,
ShortcutKey::Num0 => egui::Key::Num0, 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::Comma => egui::Key::Comma,
ShortcutKey::Minus => egui::Key::Minus, ShortcutKey::Minus => egui::Key::Minus,
ShortcutKey::Equals => egui::Key::Equals, ShortcutKey::Equals => egui::Key::Equals,
ShortcutKey::Plus => egui::Key::Plus, ShortcutKey::Plus => egui::Key::Plus,
ShortcutKey::BracketLeft => egui::Key::OpenBracket, ShortcutKey::BracketLeft => egui::Key::OpenBracket,
ShortcutKey::BracketRight => egui::Key::CloseBracket, 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::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) input.key_pressed(key)
@ -594,9 +740,20 @@ impl MenuSystem {
None None
} }
/// Check keyboard shortcuts from egui input and return the action /// Check keyboard shortcuts from egui input and return the action.
/// This works cross-platform and complements native menus /// If a KeymapManager is provided, uses remapped bindings; otherwise falls back to static defaults.
pub fn check_shortcuts(input: &egui::InputState) -> Option<MenuAction> { 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() { for def in MenuItemDef::all_with_shortcuts() {
if let Some(shortcut) = &def.shortcut { if let Some(shortcut) = &def.shortcut {
if shortcut.matches_egui_input(input) { if shortcut.matches_egui_input(input) {
@ -606,14 +763,15 @@ impl MenuSystem {
} }
None None
} }
}
/// Render egui menu bar from the same menu structure (for Linux/Windows) /// 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; let mut action = None;
egui::MenuBar::new().ui(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| {
for menu_def in MenuItemDef::menu_structure() { 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); action = Some(a);
} }
} }
@ -623,10 +781,10 @@ impl MenuSystem {
} }
/// Recursively render a MenuDef as egui UI /// 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 { match def {
MenuDef::Item(item_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) Some(item_def.action)
} else { } else {
None None
@ -666,7 +824,7 @@ impl MenuSystem {
} else { } else {
// Normal submenu rendering // Normal submenu rendering
for child in *children { 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); action = Some(a);
ui.close(); ui.close();
} }
@ -679,8 +837,18 @@ impl MenuSystem {
} }
/// Render a single menu item with label and shortcut /// Render a single menu item with label and shortcut
fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef) -> bool { fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef, keymap: Option<&crate::keymap::KeymapManager>) -> bool {
let shortcut_text = if let Some(shortcut) = &def.shortcut { // 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) Self::format_shortcut(shortcut)
} else { } else {
String::new() String::new()
@ -733,7 +901,7 @@ impl MenuSystem {
} }
/// Format shortcut for display (e.g., "Ctrl+S") /// 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(); let mut parts = Vec::new();
if shortcut.ctrl { if shortcut.ctrl {
@ -747,36 +915,53 @@ impl MenuSystem {
} }
let key_name = match shortcut.key { let key_name = match shortcut.key {
ShortcutKey::A => "A", ShortcutKey::A => "A", ShortcutKey::B => "B", ShortcutKey::C => "C",
ShortcutKey::C => "C", ShortcutKey::D => "D", ShortcutKey::E => "E", ShortcutKey::F => "F",
ShortcutKey::D => "D", ShortcutKey::G => "G", ShortcutKey::H => "H", ShortcutKey::I => "I",
ShortcutKey::E => "E", ShortcutKey::J => "J", ShortcutKey::K => "K", ShortcutKey::L => "L",
ShortcutKey::G => "G", ShortcutKey::M => "M", ShortcutKey::N => "N", ShortcutKey::O => "O",
ShortcutKey::I => "I", ShortcutKey::P => "P", ShortcutKey::Q => "Q", ShortcutKey::R => "R",
ShortcutKey::K => "K", ShortcutKey::S => "S", ShortcutKey::T => "T", ShortcutKey::U => "U",
ShortcutKey::L => "L", ShortcutKey::V => "V", ShortcutKey::W => "W", ShortcutKey::X => "X",
ShortcutKey::N => "N", ShortcutKey::Y => "Y", ShortcutKey::Z => "Z",
ShortcutKey::O => "O", ShortcutKey::Num0 => "0", ShortcutKey::Num1 => "1", ShortcutKey::Num2 => "2",
ShortcutKey::Q => "Q", ShortcutKey::Num3 => "3", ShortcutKey::Num4 => "4", ShortcutKey::Num5 => "5",
ShortcutKey::S => "S", ShortcutKey::Num6 => "6", ShortcutKey::Num7 => "7", ShortcutKey::Num8 => "8",
ShortcutKey::V => "V", ShortcutKey::Num9 => "9",
ShortcutKey::W => "W", ShortcutKey::F1 => "F1", ShortcutKey::F2 => "F2", ShortcutKey::F3 => "F3",
ShortcutKey::X => "X", ShortcutKey::F4 => "F4", ShortcutKey::F5 => "F5", ShortcutKey::F6 => "F6",
ShortcutKey::Z => "Z", ShortcutKey::F7 => "F7", ShortcutKey::F8 => "F8", ShortcutKey::F9 => "F9",
ShortcutKey::Num0 => "0", ShortcutKey::F10 => "F10", ShortcutKey::F11 => "F11", ShortcutKey::F12 => "F12",
ShortcutKey::Comma => ",", ShortcutKey::ArrowUp => "Up", ShortcutKey::ArrowDown => "Down",
ShortcutKey::Minus => "-", ShortcutKey::ArrowLeft => "Left", ShortcutKey::ArrowRight => "Right",
ShortcutKey::Equals => "=", ShortcutKey::Comma => ",", ShortcutKey::Minus => "-",
ShortcutKey::Plus => "+", ShortcutKey::Equals => "=", ShortcutKey::Plus => "+",
ShortcutKey::BracketLeft => "[", ShortcutKey::BracketLeft => "[", ShortcutKey::BracketRight => "]",
ShortcutKey::BracketRight => "]", ShortcutKey::Semicolon => ";", ShortcutKey::Quote => "'",
ShortcutKey::Delete => "Del", 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.push(key_name);
parts.join("+") 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) /// Update menu item text dynamically (e.g., for Undo/Redo with action names)
#[allow(dead_code)] #[allow(dead_code)]
pub fn update_undo_text(&self, action_name: Option<&str>) { 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, /// 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 /// so main.rs skips its own clipboard handling for the current frame
pub clipboard_consumed: &'a mut bool, 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) /// Test mode state for event recording (debug builds only)
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub test_mode: &'a mut crate::test_mode::TestModeState, 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) // Handle pane-local keyboard shortcuts (only when pointer is over this pane)
if ui.rect_contains_pointer(rect) { if ui.rect_contains_pointer(rect) {
let ctrl_g = ui.input(|i| { 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() { if ctrl_g && !self.state.selected_nodes.is_empty() {
self.group_selected_nodes(shared); self.group_selected_nodes(shared);
@ -3095,7 +3095,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
// Ctrl+Shift+G to ungroup // Ctrl+Shift+G to ungroup
let ctrl_shift_g = ui.input(|i| { 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 { if ctrl_shift_g {
// Ungroup any selected group placeholders // Ungroup any selected group placeholders
@ -3108,7 +3108,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
} }
// F2 to rename selected group // 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() { if f2 && self.renaming_group.is_none() {
// Find the first selected group placeholder // Find the first selected group placeholder
if let Some(group_id) = self.state.selected_nodes.iter() if let Some(group_id) = self.state.selected_nodes.iter()

View File

@ -725,7 +725,7 @@ impl PianoRollPane {
} }
// Delete key // 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 delete_pressed && !self.selected_note_indices.is_empty() {
if let Some(clip_id) = self.selected_clip_id { if let Some(clip_id) = self.selected_clip_id {
self.delete_selected_notes(clip_id, shared, clip_data); self.delete_selected_notes(clip_id, shared, clip_data);

View File

@ -6033,7 +6033,7 @@ impl StagePane {
{ self.replay_override = None; } { self.replay_override = None; }
// Delete/Backspace: remove selected DCEL elements // 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 shared.selection.has_dcel_selection() {
if let Some(active_layer_id) = *shared.active_layer_id { if let Some(active_layer_id) = *shared.active_layer_id {
let time = *shared.playback_time; let time = *shared.playback_time;

View File

@ -1,16 +1,30 @@
//! Preferences dialog UI //! 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 eframe::egui;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::keymap::{self, AppAction, KeymapManager};
use crate::menu::{MenuSystem, Shortcut, ShortcutKey};
use crate::theme::{Theme, ThemeMode}; 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 /// Preferences dialog state
pub struct PreferencesDialog { pub struct PreferencesDialog {
/// Is the dialog open? /// Is the dialog open?
pub open: bool, pub open: bool,
/// Currently selected tab
tab: PreferencesTab,
/// Working copy of preferences (allows cancel to discard changes) /// Working copy of preferences (allows cancel to discard changes)
working_prefs: PreferencesState, working_prefs: PreferencesState,
@ -19,6 +33,16 @@ pub struct PreferencesDialog {
/// Error message (if validation fails) /// Error message (if validation fails)
error_message: Option<String>, 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) /// Editable preferences state (working copy)
@ -74,19 +98,24 @@ impl Default for PreferencesState {
} }
/// Result returned when preferences are saved /// Result returned when preferences are saved
#[derive(Debug, Clone)]
pub struct PreferencesSaveResult { pub struct PreferencesSaveResult {
/// Whether audio buffer size changed (requires restart) /// Whether audio buffer size changed (requires restart)
pub buffer_size_changed: bool, 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 { impl Default for PreferencesDialog {
fn default() -> Self { fn default() -> Self {
Self { Self {
open: false, open: false,
tab: PreferencesTab::General,
working_prefs: PreferencesState::default(), working_prefs: PreferencesState::default(),
original_buffer_size: 256, original_buffer_size: 256,
error_message: None, 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.working_prefs = PreferencesState::from((config, theme));
self.original_buffer_size = config.audio_buffer_size; self.original_buffer_size = config.audio_buffer_size;
self.error_message = None; self.error_message = None;
self.working_keybindings = config.keybindings.effective_bindings();
self.rebinding = None;
self.shortcut_filter.clear();
} }
/// Close the dialog /// Close the dialog
pub fn close(&mut self) { pub fn close(&mut self) {
self.open = false; self.open = false;
self.error_message = None; self.error_message = None;
self.rebinding = None;
} }
/// Render the preferences dialog /// Render the preferences dialog
@ -129,7 +162,7 @@ impl PreferencesDialog {
.collapsible(false) .collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.show(ctx, |ui| { .show(ctx, |ui| {
ui.set_width(500.0); ui.set_width(550.0);
// Error message // Error message
if let Some(error) = &self.error_message { if let Some(error) = &self.error_message {
@ -137,7 +170,16 @@ impl PreferencesDialog {
ui.add_space(8.0); 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() egui::ScrollArea::vertical()
.max_height(400.0) .max_height(400.0)
.show(ui, |ui| { .show(ui, |ui| {
@ -151,6 +193,11 @@ impl PreferencesDialog {
ui.add_space(8.0); ui.add_space(8.0);
self.render_advanced_section(ui); self.render_advanced_section(ui);
}); });
}
PreferencesTab::Shortcuts => {
self.render_shortcuts_tab(ui);
}
}
ui.add_space(16.0); ui.add_space(16.0);
@ -187,6 +234,184 @@ impl PreferencesDialog {
None 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) { fn render_general_section(&mut self, ui: &mut egui::Ui) {
egui::CollapsingHeader::new("General") egui::CollapsingHeader::new("General")
.default_open(true) .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) { fn reset_to_defaults(&mut self) {
self.working_prefs = PreferencesState::default(); self.working_prefs = PreferencesState::default();
self.working_keybindings = keymap::all_defaults();
self.rebinding = None;
self.error_message = None; self.error_message = None;
} }
@ -378,6 +605,18 @@ impl PreferencesDialog {
// Check if buffer size changed // Check if buffer size changed
let buffer_size_changed = self.working_prefs.audio_buffer_size != self.original_buffer_size; 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 // Apply changes to config
config.bpm = self.working_prefs.bpm; config.bpm = self.working_prefs.bpm;
config.framerate = self.working_prefs.framerate; config.framerate = self.working_prefs.framerate;
@ -390,6 +629,7 @@ impl PreferencesDialog {
config.debug = self.working_prefs.debug; config.debug = self.working_prefs.debug;
config.waveform_stereo = self.working_prefs.waveform_stereo; config.waveform_stereo = self.working_prefs.waveform_stereo;
config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
config.keybindings = keybinding_config;
// Apply theme immediately // Apply theme immediately
theme.set_mode(self.working_prefs.theme_mode); theme.set_mode(self.working_prefs.theme_mode);
@ -402,6 +642,7 @@ impl PreferencesDialog {
Some(PreferencesSaveResult { Some(PreferencesSaveResult {
buffer_size_changed, buffer_size_changed,
new_keymap: Some(new_keymap),
}) })
} }
} }