make keyboard shortcuts configurable
This commit is contained in:
parent
353aec3513
commit
1cc7029321
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue