616 lines
25 KiB
Rust
616 lines
25 KiB
Rust
//! 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 }
|
|
}
|
|
}
|