playa 0.1.142

Image sequence player (EXR, PNG, JPEG, TIFF, .MP4). Pure Rust with optional OpenEXR/FFmpeg support.
Documentation
//! Hotkey system - keyboard shortcuts management

use crate::dialogs::prefs::prefs_events::HotkeyWindow;
use crate::core::event_bus::BoxedEvent;
use crate::core::player_events::*;
use crate::widgets::project::project_events::*;
use crate::entities::comp_events::*;
use crate::widgets::timeline::timeline_events::*;
use crate::widgets::viewport::viewport_events::*;
use crate::widgets::viewport::tool::{ToolMode, SetToolEvent};
use crate::widgets::node_editor::node_events::*;
use crate::dialogs::prefs::prefs_events::*;
use eframe::egui;
use std::collections::HashMap;
use uuid::Uuid;

/// Factory function type for creating events
type EventFactory = Box<dyn Fn() -> BoxedEvent + Send + Sync>;

/// Hotkey handler for managing keyboard shortcuts
pub struct HotkeyHandler {
    bindings: HashMap<(HotkeyWindow, String), EventFactory>,
    focused_window: HotkeyWindow,
}

impl Default for HotkeyHandler {
    fn default() -> Self {
        Self::new()
    }
}

impl HotkeyHandler {
    /// Create new hotkey handler
    pub fn new() -> Self {
        Self {
            bindings: HashMap::new(),
            focused_window: HotkeyWindow::Global,
        }
    }

    /// Handle key press, returns cloned event
    pub fn handle_key(&self, key: &str) -> Option<BoxedEvent> {
        // Try current focused window first
        if let Some(factory) = self.bindings.get(&(self.focused_window, key.to_string())) {
            log::trace!("Hotkey: ({:?}, {}) -> matched", self.focused_window, key);
            return Some(factory());
        }
        // Fallback: try Global
        if self.focused_window != HotkeyWindow::Global
            && let Some(factory) = self.bindings.get(&(HotkeyWindow::Global, key.to_string())) {
                log::trace!("Hotkey: (Global, {}) -> matched (fallback)", key);
                return Some(factory());
            }
        log::trace!("Hotkey: ({:?}, {}) -> NO MATCH", self.focused_window, key);
        None
    }

    /// Handle key with modifiers
    pub fn handle_key_with_modifiers(
        &self,
        key: &str,
        ctrl: bool,
        shift: bool,
        alt: bool,
    ) -> Option<BoxedEvent> {
        let mut key_combo = String::new();
        if ctrl { key_combo.push_str("Ctrl+"); }
        if shift { key_combo.push_str("Shift+"); }
        if alt { key_combo.push_str("Alt+"); }
        key_combo.push_str(key);
        self.handle_key(&key_combo)
    }

    /// Set focused window context
    pub fn set_focused_window(&mut self, window: HotkeyWindow) {
        self.focused_window = window;
    }

    /// Add hotkey binding with factory
    fn bind<E: Clone + Send + Sync + 'static>(&mut self, window: HotkeyWindow, key: &str, event: E) {
        let factory: EventFactory = Box::new(move || Box::new(event.clone()));
        self.bindings.insert((window, key.to_string()), factory);
    }

    /// Setup default hotkey bindings
    pub fn setup_default_bindings(&mut self) {
        use HotkeyWindow::*;

        // Global hotkeys
        self.bind(Global, "F1", ToggleHelpEvent);
        self.bind(Global, "F2", TogglePlaylistEvent);
        self.bind(Global, "F3", ToggleAttributeEditorEvent);
        self.bind(Global, "F4", ToggleEncodeDialogEvent);
        self.bind(Global, "F12", ToggleSettingsEvent);
        self.bind(Global, "Space", TogglePlayPauseEvent);
        self.bind(Global, "Insert", TogglePlayPauseEvent);  // KP_Ins / Insert
        self.bind(Global, "ArrowUp", TogglePlayPauseEvent);
        self.bind(Global, "K", StopEvent);
        self.bind(Global, "Slash", StopEvent);        // / = K (stop)
        // Num1/Num2 reserved for timeline bookmarks
        self.bind(Global, "Home", JumpToStartEvent);
        self.bind(Global, "End", JumpToEndEvent);
        self.bind(Global, "PageDown", StepForwardEvent);
        self.bind(Global, "Shift+PageDown", StepForwardLargeEvent);
        self.bind(Global, "PageUp", StepBackwardEvent);
        self.bind(Global, "Shift+PageUp", StepBackwardLargeEvent);
        self.bind(Global, "Ctrl+PageDown", JumpToEndEvent);
        self.bind(Global, "Ctrl+PageUp", JumpToStartEvent);
        // FPS control: both regular and numpad +/-
        self.bind(Global, "Minus", DecreaseFPSBaseEvent);
        self.bind(Global, "Equals", IncreaseFPSBaseEvent);
        self.bind(Global, "Plus", IncreaseFPSBaseEvent);
        self.bind(Global, "Shift+ArrowLeft", StepBackwardLargeEvent);
        self.bind(Global, "Shift+ArrowRight", StepForwardLargeEvent);
        self.bind(Global, "ArrowLeft", StepBackwardEvent);
        self.bind(Global, "ArrowRight", StepForwardEvent);
        self.bind(Global, "ArrowDown", StopEvent);
        // J/K/L style: < = J, / = K, > = L
        self.bind(Global, "J", JogBackwardEvent);
        self.bind(Global, "Comma", DecreaseFPSBaseEvent);  // , = decrease base FPS
        self.bind(Global, "L", JogForwardEvent);
        self.bind(Global, "Period", IncreaseFPSBaseEvent);  // . = increase base FPS
        self.bind(Global, "Semicolon", JumpToPrevEdgeEvent);
        self.bind(Global, "Quote", JumpToNextEdgeEvent);
        self.bind(Global, "Backtick", ToggleLoopEvent);
        self.bind(Global, "Backspace", ToggleFrameNumbersEvent);
        self.bind(Global, "B", SetPlayRangeStartEvent);
        self.bind(Global, "N", SetPlayRangeEndEvent);
        self.bind(Global, "Ctrl+B", ResetPlayRangeEvent);
        self.bind(Global, "Ctrl+ArrowLeft", JumpToStartEvent);
        self.bind(Global, "Ctrl+ArrowRight", JumpToEndEvent);
        // Ctrl+R is now ResetTrimsEvent in Timeline context (see below)
        self.bind(Global, "Ctrl+S", QuickSaveEvent);
        self.bind(Global, "Ctrl+O", OpenProjectDialogEvent);
        self.bind(Global, "Z", ToggleFullscreenEvent);
        self.bind(Global, "U", ProjectPreviousCompEvent);
        self.bind(Global, "Ctrl+Alt+Slash", ClearCacheEvent);  // Clear all cached frames
        self.bind(Global, "F", FitViewportEvent);
        self.bind(Global, "A", Viewport100Event);
        self.bind(Global, "H", Viewport100Event);
        // Tool hotkeys (Q/W/E/R like Maya)
        self.bind(Global, "Q", SetToolEvent(ToolMode::Select));
        self.bind(Global, "W", SetToolEvent(ToolMode::Move));
        self.bind(Global, "E", SetToolEvent(ToolMode::Rotate));
        self.bind(Global, "R", SetToolEvent(ToolMode::Scale));

        // Timeline-specific
        self.bind(Timeline, "Delete", RemoveSelectedLayerEvent);
        self.bind(Timeline, "F", TimelineFitEvent::selected());  // Fit to selected (or all if none)
        self.bind(Timeline, "A", TimelineFitWorkAreaEvent);        // Fit to work area (B/N range)
        self.bind(Timeline, "OpenBracket", AlignLayersStartEvent(Uuid::nil()));
        self.bind(Timeline, "CloseBracket", AlignLayersEndEvent(Uuid::nil()));
        self.bind(Timeline, "Alt+OpenBracket", TrimLayersStartEvent(Uuid::nil()));
        self.bind(Timeline, "Alt+CloseBracket", TrimLayersEndEvent(Uuid::nil()));
        // Layer clipboard operations
        self.bind(Timeline, "Ctrl+D", DuplicateLayersEvent { comp_uuid: Uuid::nil() });
        self.bind(Timeline, "Ctrl+C", CopyLayersEvent { comp_uuid: Uuid::nil() });
        self.bind(Timeline, "Ctrl+V", PasteLayersEvent { comp_uuid: Uuid::nil(), target_frame: 0 });
        // Selection operations
        self.bind(Timeline, "Ctrl+A", SelectAllLayersEvent { comp_uuid: Uuid::nil() });
        self.bind(Timeline, "F2", ClearLayerSelectionEvent { comp_uuid: Uuid::nil() }); // Overrides global F2 in timeline
        // Trim operations
        self.bind(Timeline, "Ctrl+R", ResetTrimsEvent { comp_uuid: Uuid::nil() });

        // Project-specific
        self.bind(Project, "Delete", RemoveSelectedMediaEvent);

        // Viewport-specific
        self.bind(Viewport, "F", FitViewportEvent);
        self.bind(Viewport, "A", Viewport100Event);
        self.bind(Viewport, "H", Viewport100Event);

        // Node editor-specific
        self.bind(NodeEditor, "A", NodeEditorFitAllEvent);
        self.bind(NodeEditor, "F", NodeEditorFitSelectedEvent);
        self.bind(NodeEditor, "L", NodeEditorLayoutEvent);
    }

    /// Handle keyboard input
    pub fn handle_input(&self, input: &egui::InputState) -> Option<BoxedEvent> {
        for event in &input.events {
            // Handle egui's semantic Copy/Cut/Paste events (Ctrl+C/X/V are converted to these)
            match event {
                egui::Event::Copy => {
                    log::trace!("Event::Copy (window={:?})", self.focused_window);
                    if let Some(ev) = self.handle_key("Ctrl+C") {
                        return Some(ev);
                    }
                }
                egui::Event::Cut => {
                    log::trace!("Event::Cut (window={:?})", self.focused_window);
                    if let Some(ev) = self.handle_key("Ctrl+X") {
                        return Some(ev);
                    }
                }
                egui::Event::Paste(_) => {
                    log::trace!("Event::Paste (window={:?})", self.focused_window);
                    if let Some(ev) = self.handle_key("Ctrl+V") {
                        return Some(ev);
                    }
                }
                egui::Event::Key {
                    key,
                    pressed: true,
                    modifiers,
                    ..
                } => {
                    let key_str = format!("{:?}", key);

                    // Build combo string for debug
                    let mut combo = String::new();
                    if modifiers.ctrl { combo.push_str("Ctrl+"); }
                    if modifiers.shift { combo.push_str("Shift+"); }
                    if modifiers.alt { combo.push_str("Alt+"); }
                    combo.push_str(&key_str);

                    if let Some(ev) = self.handle_key_with_modifiers(
                        &key_str,
                        modifiers.ctrl,
                        modifiers.shift,
                        modifiers.alt,
                    ) {
                        log::trace!("[HOTKEY] matched: {} (window={:?})", combo, self.focused_window);
                        return Some(ev);
                    }
                    if !modifiers.any()
                        && let Some(ev) = self.handle_key(&key_str) {
                            log::trace!("[HOTKEY] matched (no mod): {} (window={:?})", key_str, self.focused_window);
                            return Some(ev);
                        }
                    // Debug: log F/A when NOT matched
                    if !modifiers.any() && (key_str == "F" || key_str == "A") {
                        log::trace!("[HOTKEY] F/A NOT matched: key={} window={:?}", key_str, self.focused_window);
                    }
                }
                _ => {}
            }
        }
        None
    }
}