use eframe::egui; use lightningbeam_core::layer::{AnyLayer, AudioLayer}; use lightningbeam_core::layout::{LayoutDefinition, LayoutNode}; use lightningbeam_core::pane::PaneType; use lightningbeam_core::tool::Tool; use std::collections::HashMap; use clap::Parser; use uuid::Uuid; mod panes; use panes::{PaneInstance, PaneRenderer, SharedPaneState}; mod widgets; mod menu; use menu::{MenuAction, MenuSystem}; mod theme; use theme::{Theme, ThemeMode}; mod waveform_image_cache; mod config; use config::AppConfig; mod default_instrument; /// Lightningbeam Editor - Animation and video editing software #[derive(Parser, Debug)] #[command(name = "Lightningbeam Editor")] #[command(author, version, about, long_about = None)] struct Args { /// Use light theme #[arg(long, conflicts_with = "dark")] light: bool, /// Use dark theme #[arg(long, conflicts_with = "light")] dark: bool, } fn main() -> eframe::Result { println!("🚀 Starting Lightningbeam Editor..."); // Parse command line arguments let args = Args::parse(); // Determine theme mode from arguments let theme_mode = if args.light { ThemeMode::Light } else if args.dark { ThemeMode::Dark } else { ThemeMode::System }; // Load theme let mut theme = Theme::load_default().expect("Failed to load theme"); theme.set_mode(theme_mode); println!("✅ Loaded theme with {} selectors (mode: {:?})", theme.len(), theme_mode); // Debug: print theme info theme.debug_print(); // Load layouts from JSON let layouts = load_layouts(); println!("✅ Loaded {} layouts", layouts.len()); for layout in &layouts { println!(" - {}: {}", layout.name, layout.description); } // Initialize native menus for macOS (app-wide, doesn't need window) #[cfg(target_os = "macos")] { if let Ok(menu_system) = MenuSystem::new() { menu_system.init_for_macos(); println!("✅ Native macOS menus initialized"); } } // Load window icon let icon_data = include_bytes!("../../../src-tauri/icons/icon.png"); let icon_image = match image::load_from_memory(icon_data) { Ok(img) => { let rgba = img.to_rgba8(); let (width, height) = (rgba.width(), rgba.height()); println!("✅ Loaded window icon: {}x{}", width, height); Some(egui::IconData { rgba: rgba.into_raw(), width, height, }) } Err(e) => { eprintln!("❌ Failed to load window icon: {}", e); None } }; let mut viewport_builder = egui::ViewportBuilder::default() .with_inner_size([1920.0, 1080.0]) .with_title("Lightningbeam Editor") .with_app_id("lightningbeam-editor"); // Set app_id for Wayland if let Some(icon) = icon_image { viewport_builder = viewport_builder.with_icon(icon); } let options = eframe::NativeOptions { viewport: viewport_builder, ..Default::default() }; eframe::run_native( "Lightningbeam Editor", options, Box::new(move |cc| Ok(Box::new(EditorApp::new(cc, layouts, theme)))), ) } fn load_layouts() -> Vec { let json = include_str!("../assets/layouts.json"); serde_json::from_str(json).expect("Failed to parse layouts.json") } /// Path to a node in the layout tree (indices of children) type NodePath = Vec; #[derive(Default)] struct DragState { is_dragging: bool, node_path: NodePath, is_horizontal: bool, } /// Action to perform on the layout tree enum LayoutAction { SplitHorizontal(NodePath, f32), // path, percent SplitVertical(NodePath, f32), // path, percent RemoveSplit(NodePath), EnterSplitPreviewHorizontal, EnterSplitPreviewVertical, } #[derive(Default)] enum SplitPreviewMode { #[default] None, Active { is_horizontal: bool, hovered_pane: Option, split_percent: f32, }, } /// Icon cache for pane type icons struct IconCache { icons: HashMap, assets_path: std::path::PathBuf, } impl IconCache { fn new() -> Self { let assets_path = std::path::PathBuf::from( std::env::var("HOME").unwrap_or_else(|_| "/home/skyler".to_string()) ).join("Dev/Lightningbeam-2/src/assets"); Self { icons: HashMap::new(), assets_path, } } fn get_or_load(&mut self, pane_type: PaneType) -> Option<&egui_extras::RetainedImage> { if !self.icons.contains_key(&pane_type) { // Load and cache the icon let icon_path = self.assets_path.join(pane_type.icon_file()); if let Ok(image) = egui_extras::RetainedImage::from_svg_bytes( pane_type.icon_file(), &std::fs::read(&icon_path).unwrap_or_default(), ) { self.icons.insert(pane_type, image); } } self.icons.get(&pane_type) } } /// Icon cache for tool icons struct ToolIconCache { icons: HashMap, assets_path: std::path::PathBuf, } impl ToolIconCache { fn new() -> Self { let assets_path = std::path::PathBuf::from( std::env::var("HOME").unwrap_or_else(|_| "/home/skyler".to_string()) ).join("Dev/Lightningbeam-2/src/assets"); Self { icons: HashMap::new(), assets_path, } } fn get_or_load(&mut self, tool: Tool, ctx: &egui::Context) -> Option<&egui::TextureHandle> { if !self.icons.contains_key(&tool) { // Load SVG and rasterize at high resolution using resvg let icon_path = self.assets_path.join(tool.icon_file()); if let Ok(svg_data) = std::fs::read(&icon_path) { // Rasterize at 3x size for crisp display (180px for 60px display) let render_size = 180; if let Ok(tree) = resvg::usvg::Tree::from_data(&svg_data, &resvg::usvg::Options::default()) { let pixmap_size = tree.size().to_int_size(); let scale_x = render_size as f32 / pixmap_size.width() as f32; let scale_y = render_size as f32 / pixmap_size.height() as f32; let scale = scale_x.min(scale_y); let final_size = resvg::usvg::Size::from_wh( pixmap_size.width() as f32 * scale, pixmap_size.height() as f32 * scale, ).unwrap_or(resvg::usvg::Size::from_wh(render_size as f32, render_size as f32).unwrap()); if let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new( final_size.width() as u32, final_size.height() as u32, ) { let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); resvg::render(&tree, transform, &mut pixmap.as_mut()); // Convert RGBA8 to egui ColorImage let rgba_data = pixmap.data(); let size = [pixmap.width() as usize, pixmap.height() as usize]; let color_image = egui::ColorImage::from_rgba_unmultiplied(size, rgba_data); // Upload to GPU let texture = ctx.load_texture( tool.icon_file(), color_image, egui::TextureOptions::LINEAR, ); self.icons.insert(tool, texture); } } } } self.icons.get(&tool) } } /// Command sent to file operations worker thread enum FileCommand { Save { path: std::path::PathBuf, document: lightningbeam_core::document::Document, progress_tx: std::sync::mpsc::Sender, }, Load { path: std::path::PathBuf, progress_tx: std::sync::mpsc::Sender, }, } /// Progress updates from file operations worker enum FileProgress { SerializingAudioPool, EncodingAudio { current: usize, total: usize }, WritingZip, LoadingProject, DecodingAudio { current: usize, total: usize }, Complete(Result), // For loading Error(String), Done, } /// Active file operation state enum FileOperation { Saving { path: std::path::PathBuf, progress_rx: std::sync::mpsc::Receiver, }, Loading { path: std::path::PathBuf, progress_rx: std::sync::mpsc::Receiver, }, } /// Worker thread for file operations (save/load) struct FileOperationsWorker { command_rx: std::sync::mpsc::Receiver, audio_controller: std::sync::Arc>, } impl FileOperationsWorker { /// Create a new worker and spawn it on a background thread fn spawn(audio_controller: std::sync::Arc>) -> std::sync::mpsc::Sender { let (command_tx, command_rx) = std::sync::mpsc::channel(); let worker = FileOperationsWorker { command_rx, audio_controller, }; std::thread::spawn(move || { worker.run(); }); command_tx } /// Main worker loop - processes file commands fn run(self) { while let Ok(command) = self.command_rx.recv() { match command { FileCommand::Save { path, document, progress_tx } => { self.handle_save(path, document, progress_tx); } FileCommand::Load { path, progress_tx } => { self.handle_load(path, progress_tx); } } } } /// Handle save command fn handle_save( &self, path: std::path::PathBuf, document: lightningbeam_core::document::Document, progress_tx: std::sync::mpsc::Sender, ) { use lightningbeam_core::file_io::{save_beam, SaveSettings}; let save_start = std::time::Instant::now(); eprintln!("📊 [SAVE] Starting save operation..."); // Step 1: Serialize audio pool let _ = progress_tx.send(FileProgress::SerializingAudioPool); let step1_start = std::time::Instant::now(); let audio_pool_entries = { let mut controller = self.audio_controller.lock().unwrap(); match controller.serialize_audio_pool(&path) { Ok(entries) => entries, Err(e) => { let _ = progress_tx.send(FileProgress::Error(format!("Failed to serialize audio pool: {}", e))); return; } } }; eprintln!("📊 [SAVE] Step 1: Serialize audio pool took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0); // Step 2: Get project let step2_start = std::time::Instant::now(); let mut audio_project = { let mut controller = self.audio_controller.lock().unwrap(); match controller.get_project() { Ok(p) => p, Err(e) => { let _ = progress_tx.send(FileProgress::Error(format!("Failed to get project: {}", e))); return; } } }; eprintln!("📊 [SAVE] Step 2: Get audio project took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0); // Step 3: Save to file let _ = progress_tx.send(FileProgress::WritingZip); let step3_start = std::time::Instant::now(); let settings = SaveSettings::default(); match save_beam(&path, &document, &mut audio_project, audio_pool_entries, &settings) { Ok(()) => { eprintln!("📊 [SAVE] Step 3: save_beam() took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [SAVE] ✅ Total save time: {:.2}ms", save_start.elapsed().as_secs_f64() * 1000.0); println!("✅ Saved to: {}", path.display()); let _ = progress_tx.send(FileProgress::Done); } Err(e) => { let _ = progress_tx.send(FileProgress::Error(format!("Save failed: {}", e))); } } } /// Handle load command fn handle_load( &self, path: std::path::PathBuf, progress_tx: std::sync::mpsc::Sender, ) { use lightningbeam_core::file_io::load_beam; let load_start = std::time::Instant::now(); eprintln!("📊 [LOAD] Starting load operation..."); // Step 1: Load from file let _ = progress_tx.send(FileProgress::LoadingProject); let step1_start = std::time::Instant::now(); let loaded_project = match load_beam(&path) { Ok(p) => p, Err(e) => { let _ = progress_tx.send(FileProgress::Error(format!("Load failed: {}", e))); return; } }; eprintln!("📊 [LOAD] Step 1: load_beam() took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0); // Check for missing files if !loaded_project.missing_files.is_empty() { eprintln!("âš ī¸ {} missing files", loaded_project.missing_files.len()); for missing in &loaded_project.missing_files { eprintln!(" - {}", missing.original_path.display()); } } eprintln!("📊 [LOAD] ✅ Total load time: {:.2}ms", load_start.elapsed().as_secs_f64() * 1000.0); // Send the loaded project back to UI thread for processing let _ = progress_tx.send(FileProgress::Complete(Ok(loaded_project))); } } struct EditorApp { layouts: Vec, current_layout_index: usize, current_layout: LayoutNode, // Mutable copy for editing drag_state: DragState, hovered_divider: Option<(NodePath, bool)>, // (path, is_horizontal) selected_pane: Option, // Currently selected pane for editing split_preview_mode: SplitPreviewMode, icon_cache: IconCache, tool_icon_cache: ToolIconCache, selected_tool: Tool, // Currently selected drawing tool fill_color: egui::Color32, // Fill color for drawing stroke_color: egui::Color32, // Stroke color for drawing active_color_mode: panes::ColorMode, // Which color (fill/stroke) was last interacted with pane_instances: HashMap, // Pane instances per path menu_system: Option, // Native menu system for event checking pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane theme: Theme, // Theme system for colors and dimensions action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo active_layer_id: Option, // Currently active layer for editing selection: lightningbeam_core::selection::Selection, // Current selection state tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state // Draw tool configuration draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0) schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0) // Audio engine integration audio_stream: Option, // Audio stream (must be kept alive) audio_controller: Option>>, // Shared audio controller audio_event_rx: Option>, // Audio event receiver audio_sample_rate: u32, // Audio sample rate audio_channels: u32, // Audio channel count // Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds) layer_to_track_map: HashMap, track_to_layer_map: HashMap, // Playback state (global for all panes) playback_time: f64, // Current playback position in seconds (persistent - save with document) is_playing: bool, // Whether playback is currently active (transient - don't save) // Asset drag-and-drop state dragging_asset: Option, // Asset being dragged from Asset Library // Import dialog state last_import_filter: ImportFilter, // Last used import filter (remembered across imports) // Tool-specific options (displayed in infopanel) stroke_width: f64, // Stroke width for drawing tools (default: 3.0) fill_enabled: bool, // Whether to fill shapes (default: true) paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0) polygon_sides: u32, // Number of sides for polygon tool (default: 5) /// Cache for MIDI event data (keyed by backend midi_clip_id) /// Prevents repeated backend queries for the same MIDI clip /// Format: (timestamp, note_number, is_note_on) midi_event_cache: HashMap>, /// Cache for audio waveform data (keyed by audio_pool_index) /// Prevents repeated backend queries for the same audio file /// Format: Vec of WaveformPeak (min/max pairs) waveform_cache: HashMap>, /// Cache for rendered waveform images (GPU textures) /// Stores pre-rendered waveform tiles at various zoom levels for fast blitting waveform_image_cache: waveform_image_cache::WaveformImageCache, /// Current file path (None if not yet saved) current_file_path: Option, /// Application configuration (recent files, etc.) config: AppConfig, /// File operations worker command sender file_command_tx: std::sync::mpsc::Sender, /// Current file operation in progress (if any) file_operation: Option, } /// Import filter types for the file dialog #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum ImportFilter { #[default] All, Images, Audio, Video, Midi, } impl EditorApp { fn new(cc: &eframe::CreationContext, layouts: Vec, theme: Theme) -> Self { let current_layout = layouts[0].layout.clone(); // Load application config let config = AppConfig::load(); // Initialize native menu system let mut menu_system = MenuSystem::new().ok(); // Populate recent files menu if let Some(ref mut menu_sys) = menu_system { let recent_files = config.get_recent_files(); menu_sys.update_recent_files(&recent_files); } // Create default document with a simple test scene let mut document = lightningbeam_core::document::Document::with_size("Untitled Animation", 1920.0, 1080.0) .with_duration(10.0) .with_framerate(60.0); // Add a test layer with a simple shape to visualize use lightningbeam_core::layer::{AnyLayer, VectorLayer}; use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::shape::{Shape, ShapeColor}; use vello::kurbo::{Circle, Shape as KurboShape}; // Create circle centered at origin, position via instance transform let circle = Circle::new((0.0, 0.0), 50.0); let path = circle.to_path(0.1); let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); let object = ShapeInstance::new(shape.id).with_position(200.0, 150.0); let mut vector_layer = VectorLayer::new("Layer 1"); vector_layer.add_shape(shape); vector_layer.add_object(object); let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); // Wrap document in ActionExecutor let action_executor = lightningbeam_core::action::ActionExecutor::new(document); // Initialize audio system and destructure it for sharing let (audio_stream, audio_controller, audio_event_rx, audio_sample_rate, audio_channels, file_command_tx) = match daw_backend::AudioSystem::new(None, 256) { Ok(audio_system) => { println!("✅ Audio engine initialized successfully"); // Extract components let stream = audio_system.stream; let sample_rate = audio_system.sample_rate; let channels = audio_system.channels; let event_rx = audio_system.event_rx; // Wrap controller in Arc> for sharing with worker thread let controller = std::sync::Arc::new(std::sync::Mutex::new(audio_system.controller)); // Spawn file operations worker let file_command_tx = FileOperationsWorker::spawn(controller.clone()); (Some(stream), Some(controller), event_rx, sample_rate, channels, file_command_tx) } Err(e) => { eprintln!("❌ Failed to initialize audio engine: {}", e); eprintln!(" Playback will be disabled"); // Create a dummy channel for file operations (won't be used) let (tx, _rx) = std::sync::mpsc::channel(); (None, None, None, 48000, 2, tx) } }; Self { layouts, current_layout_index: 0, current_layout, drag_state: DragState::default(), hovered_divider: None, selected_pane: None, split_preview_mode: SplitPreviewMode::default(), icon_cache: IconCache::new(), tool_icon_cache: ToolIconCache::new(), selected_tool: Tool::Select, // Default tool fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke active_color_mode: panes::ColorMode::default(), // Default to fill color pane_instances: HashMap::new(), // Initialize empty, panes created on-demand menu_system, pending_view_action: None, theme, action_executor, active_layer_id: Some(layer_id), selection: lightningbeam_core::selection::Selection::new(), tool_state: lightningbeam_core::tool::ToolState::default(), draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves rdp_tolerance: 10.0, // Default RDP tolerance schneider_max_error: 30.0, // Default Schneider max error audio_stream, audio_controller, audio_event_rx, audio_sample_rate, audio_channels, layer_to_track_map: HashMap::new(), track_to_layer_map: HashMap::new(), playback_time: 0.0, // Start at beginning is_playing: false, // Start paused dragging_asset: None, // No asset being dragged initially last_import_filter: ImportFilter::default(), // Default to "All Supported" stroke_width: 3.0, // Default stroke width fill_enabled: true, // Default to filling shapes paint_bucket_gap_tolerance: 5.0, // Default gap tolerance polygon_sides: 5, // Default to pentagon midi_event_cache: HashMap::new(), // Initialize empty MIDI event cache waveform_cache: HashMap::new(), // Initialize empty waveform cache waveform_image_cache: waveform_image_cache::WaveformImageCache::new(), // Initialize waveform image cache current_file_path: None, // No file loaded initially config, file_command_tx, file_operation: None, // No file operation in progress initially } } /// Synchronize all existing MIDI layers in the document with daw-backend tracks /// /// This function should be called: /// - After loading a document from file /// - After creating a new document with pre-existing MIDI layers /// /// For each audio layer (MIDI or Sampled): /// 1. Creates a daw-backend track (MIDI or Audio) /// 2. For MIDI: Loads the default instrument /// 3. Stores the bidirectional mapping /// 4. Syncs any existing clips on the layer fn sync_audio_layers_to_backend(&mut self) { use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; // Iterate through all layers in the document for layer in &self.action_executor.document().root.children { // Only process Audio layers if let AnyLayer::Audio(audio_layer) = layer { let layer_id = audio_layer.layer.id; let layer_name = &audio_layer.layer.name; // Skip if already mapped (shouldn't happen, but be defensive) if self.layer_to_track_map.contains_key(&layer_id) { continue; } // Handle both MIDI and Sampled audio tracks match audio_layer.audio_layer_type { AudioLayerType::Midi => { // Create daw-backend MIDI track if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); match controller.create_midi_track_sync(layer_name.clone()) { Ok(track_id) => { // Store bidirectional mapping self.layer_to_track_map.insert(layer_id, track_id); self.track_to_layer_map.insert(track_id, layer_id); // Load default instrument if let Err(e) = default_instrument::load_default_instrument(&mut *controller, track_id) { eprintln!("âš ī¸ Failed to load default instrument for {}: {}", layer_name, e); } else { println!("✅ Synced MIDI layer '{}' to backend (TrackId: {})", layer_name, track_id); } // TODO: Sync any existing clips on this layer to the backend // This will be implemented when we add clip synchronization } Err(e) => { eprintln!("âš ī¸ Failed to create daw-backend track for MIDI layer '{}': {}", layer_name, e); } } } } AudioLayerType::Sampled => { // Create daw-backend Audio track if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); match controller.create_audio_track_sync(layer_name.clone()) { Ok(track_id) => { // Store bidirectional mapping self.layer_to_track_map.insert(layer_id, track_id); self.track_to_layer_map.insert(track_id, layer_id); println!("✅ Synced Audio layer '{}' to backend (TrackId: {})", layer_name, track_id); // TODO: Sync any existing clips on this layer to the backend // This will be implemented when we add clip synchronization } Err(e) => { eprintln!("âš ī¸ Failed to create daw-backend audio track for '{}': {}", layer_name, e); } } } } } } } } /// Fetch waveform data from backend for a specific audio pool index /// Returns cached data if available, otherwise queries backend fn fetch_waveform(&mut self, pool_index: usize) -> Option> { // Check if already cached if let Some(waveform) = self.waveform_cache.get(&pool_index) { return Some(waveform.clone()); } // Fetch from backend // Request 20,000 peaks for high-detail waveform visualization // For a 200s file, this gives ~100 peaks/second, providing smooth visualization at all zoom levels if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); match controller.get_pool_waveform(pool_index, 20000) { Ok(waveform) => { self.waveform_cache.insert(pool_index, waveform.clone()); Some(waveform) } Err(e) => { eprintln!("âš ī¸ Failed to fetch waveform for pool index {}: {}", pool_index, e); None } } } else { None } } fn switch_layout(&mut self, index: usize) { self.current_layout_index = index; self.current_layout = self.layouts[index].layout.clone(); // Clear pane instances so they rebuild with new layout self.pane_instances.clear(); } fn current_layout_def(&self) -> &LayoutDefinition { &self.layouts[self.current_layout_index] } fn apply_layout_action(&mut self, action: LayoutAction) { match action { LayoutAction::SplitHorizontal(path, percent) => { split_node(&mut self.current_layout, &path, true, percent); } LayoutAction::SplitVertical(path, percent) => { split_node(&mut self.current_layout, &path, false, percent); } LayoutAction::RemoveSplit(path) => { remove_split(&mut self.current_layout, &path); } LayoutAction::EnterSplitPreviewHorizontal => { self.split_preview_mode = SplitPreviewMode::Active { is_horizontal: false, // horizontal divider = vertical grid (top/bottom) hovered_pane: None, split_percent: 50.0, }; } LayoutAction::EnterSplitPreviewVertical => { self.split_preview_mode = SplitPreviewMode::Active { is_horizontal: true, // vertical divider = horizontal grid (left/right) hovered_pane: None, split_percent: 50.0, }; } } } fn handle_menu_action(&mut self, action: MenuAction) { match action { // File menu MenuAction::NewFile => { println!("Menu: New File"); // TODO: Prompt to save current file if modified // Create new document let mut document = lightningbeam_core::document::Document::with_size("Untitled Animation", 1920.0, 1080.0) .with_duration(10.0) .with_framerate(60.0); // Add default layer use lightningbeam_core::layer::{AnyLayer, VectorLayer}; let vector_layer = VectorLayer::new("Layer 1"); let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); // Replace action executor with new document self.action_executor = lightningbeam_core::action::ActionExecutor::new(document); self.active_layer_id = Some(layer_id); // Reset audio project (send command to create new empty project) // TODO: Add ResetProject command to EngineController self.layer_to_track_map.clear(); self.track_to_layer_map.clear(); // Clear file path self.current_file_path = None; println!("Created new file"); } MenuAction::NewWindow => { println!("Menu: New Window"); // TODO: Implement new window (requires multi-window support) } MenuAction::Save => { use rfd::FileDialog; if let Some(path) = &self.current_file_path { // Save to existing path self.save_to_file(path.clone()); } else { // No current path, fall through to Save As if let Some(path) = FileDialog::new() .add_filter("Lightningbeam Project", &["beam"]) .set_file_name("Untitled.beam") .save_file() { self.save_to_file(path); } } } MenuAction::SaveAs => { use rfd::FileDialog; let dialog = FileDialog::new() .add_filter("Lightningbeam Project", &["beam"]) .set_file_name("Untitled.beam"); // Set initial directory if we have a current file let dialog = if let Some(current_path) = &self.current_file_path { if let Some(parent) = current_path.parent() { dialog.set_directory(parent) } else { dialog } } else { dialog }; if let Some(path) = dialog.save_file() { self.save_to_file(path); } } MenuAction::OpenFile => { use rfd::FileDialog; // TODO: Prompt to save current file if modified if let Some(path) = FileDialog::new() .add_filter("Lightningbeam Project", &["beam"]) .pick_file() { self.load_from_file(path); } } MenuAction::OpenRecent(index) => { let recent_files = self.config.get_recent_files(); if let Some(path) = recent_files.get(index) { // TODO: Prompt to save current file if modified self.load_from_file(path.clone()); } } MenuAction::ClearRecentFiles => { self.config.clear_recent_files(); self.update_recent_files_menu(); } MenuAction::Revert => { println!("Menu: Revert"); // TODO: Implement revert } MenuAction::Import => { use lightningbeam_core::file_types::*; use rfd::FileDialog; // Build file filter from extension constants let all_extensions: Vec<&str> = all_supported_extensions(); // Build dialog with filters in order based on last used filter // The first filter added is the default in most file dialogs let mut dialog = FileDialog::new().set_title("Import Asset"); // Add filters in order, with the last-used filter first match self.last_import_filter { ImportFilter::All => { dialog = dialog .add_filter("All Supported", &all_extensions) .add_filter("Images", IMAGE_EXTENSIONS) .add_filter("Audio", AUDIO_EXTENSIONS) .add_filter("Video", VIDEO_EXTENSIONS) .add_filter("MIDI", MIDI_EXTENSIONS); } ImportFilter::Images => { dialog = dialog .add_filter("Images", IMAGE_EXTENSIONS) .add_filter("All Supported", &all_extensions) .add_filter("Audio", AUDIO_EXTENSIONS) .add_filter("Video", VIDEO_EXTENSIONS) .add_filter("MIDI", MIDI_EXTENSIONS); } ImportFilter::Audio => { dialog = dialog .add_filter("Audio", AUDIO_EXTENSIONS) .add_filter("All Supported", &all_extensions) .add_filter("Images", IMAGE_EXTENSIONS) .add_filter("Video", VIDEO_EXTENSIONS) .add_filter("MIDI", MIDI_EXTENSIONS); } ImportFilter::Video => { dialog = dialog .add_filter("Video", VIDEO_EXTENSIONS) .add_filter("All Supported", &all_extensions) .add_filter("Images", IMAGE_EXTENSIONS) .add_filter("Audio", AUDIO_EXTENSIONS) .add_filter("MIDI", MIDI_EXTENSIONS); } ImportFilter::Midi => { dialog = dialog .add_filter("MIDI", MIDI_EXTENSIONS) .add_filter("All Supported", &all_extensions) .add_filter("Images", IMAGE_EXTENSIONS) .add_filter("Audio", AUDIO_EXTENSIONS) .add_filter("Video", VIDEO_EXTENSIONS); } } let file = dialog.pick_file(); if let Some(path) = file { // Get extension and detect file type let extension = path.extension() .and_then(|e| e.to_str()) .unwrap_or(""); match get_file_type(extension) { Some(FileType::Image) => { self.last_import_filter = ImportFilter::Images; self.import_image(&path); } Some(FileType::Audio) => { self.last_import_filter = ImportFilter::Audio; self.import_audio(&path); } Some(FileType::Video) => { self.last_import_filter = ImportFilter::Video; self.import_video(&path); } Some(FileType::Midi) => { self.last_import_filter = ImportFilter::Midi; self.import_midi(&path); } None => { println!("Unsupported file type: {}", extension); } } } } MenuAction::Export => { println!("Menu: Export"); // TODO: Implement export } MenuAction::Quit => { println!("Menu: Quit"); std::process::exit(0); } // Edit menu MenuAction::Undo => { if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, }; match self.action_executor.undo_with_backend(&mut backend_context) { Ok(true) => println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()), Ok(false) => println!("Nothing to undo"), Err(e) => eprintln!("Undo failed: {}", e), } } else { if self.action_executor.undo() { println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()); } else { println!("Nothing to undo"); } } } MenuAction::Redo => { if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, }; match self.action_executor.redo_with_backend(&mut backend_context) { Ok(true) => println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()), Ok(false) => println!("Nothing to redo"), Err(e) => eprintln!("Redo failed: {}", e), } } else { if self.action_executor.redo() { println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()); } else { println!("Nothing to redo"); } } } MenuAction::Cut => { println!("Menu: Cut"); // TODO: Implement cut } MenuAction::Copy => { println!("Menu: Copy"); // TODO: Implement copy } MenuAction::Paste => { println!("Menu: Paste"); // TODO: Implement paste } MenuAction::Delete => { println!("Menu: Delete"); // TODO: Implement delete } MenuAction::SelectAll => { println!("Menu: Select All"); // TODO: Implement select all } MenuAction::SelectNone => { println!("Menu: Select None"); // TODO: Implement select none } MenuAction::Preferences => { println!("Menu: Preferences"); // TODO: Implement preferences dialog } // Modify menu MenuAction::Group => { println!("Menu: Group"); // TODO: Implement group } MenuAction::SendToBack => { println!("Menu: Send to Back"); // TODO: Implement send to back } MenuAction::BringToFront => { println!("Menu: Bring to Front"); // TODO: Implement bring to front } // Layer menu MenuAction::AddLayer => { // Create a new vector layer with a default name let layer_count = self.action_executor.document().root.children.len(); let layer_name = format!("Layer {}", layer_count + 1); let action = lightningbeam_core::actions::AddLayerAction::new_vector(layer_name); self.action_executor.execute(Box::new(action)); // Select the newly created layer (last child in the document) if let Some(last_layer) = self.action_executor.document().root.children.last() { self.active_layer_id = Some(last_layer.id()); } } MenuAction::AddVideoLayer => { println!("Menu: Add Video Layer"); // TODO: Implement add video layer } MenuAction::AddAudioTrack => { // Create a new sampled audio layer with a default name let layer_count = self.action_executor.document().root.children.len(); let layer_name = format!("Audio Track {}", layer_count + 1); // Create audio layer in document let audio_layer = AudioLayer::new_sampled(layer_name.clone()); let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Audio(audio_layer)); self.action_executor.execute(Box::new(action)); // Get the newly created layer ID if let Some(last_layer) = self.action_executor.document().root.children.last() { let layer_id = last_layer.id(); self.active_layer_id = Some(layer_id); // Create corresponding daw-backend audio track if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); match controller.create_audio_track_sync(layer_name.clone()) { Ok(track_id) => { // Store bidirectional mapping self.layer_to_track_map.insert(layer_id, track_id); self.track_to_layer_map.insert(track_id, layer_id); println!("✅ Created {} (backend TrackId: {})", layer_name, track_id); } Err(e) => { eprintln!("âš ī¸ Failed to create daw-backend audio track for {}: {}", layer_name, e); eprintln!(" Layer created but will be silent until backend track is available"); } } } } } MenuAction::AddMidiTrack => { // Create a new MIDI audio layer with a default name let layer_count = self.action_executor.document().root.children.len(); let layer_name = format!("MIDI Track {}", layer_count + 1); // Create MIDI layer in document let midi_layer = AudioLayer::new_midi(layer_name.clone()); let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Audio(midi_layer)); self.action_executor.execute(Box::new(action)); // Get the newly created layer ID if let Some(last_layer) = self.action_executor.document().root.children.last() { let layer_id = last_layer.id(); self.active_layer_id = Some(layer_id); // Create corresponding daw-backend MIDI track if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); match controller.create_midi_track_sync(layer_name.clone()) { Ok(track_id) => { // Store bidirectional mapping self.layer_to_track_map.insert(layer_id, track_id); self.track_to_layer_map.insert(track_id, layer_id); // Load default instrument into the track if let Err(e) = default_instrument::load_default_instrument(&mut *controller, track_id) { eprintln!("âš ī¸ Failed to load default instrument for {}: {}", layer_name, e); } else { println!("✅ Created {} (backend TrackId: {}, instrument: {})", layer_name, track_id, default_instrument::default_instrument_name()); } } Err(e) => { eprintln!("âš ī¸ Failed to create daw-backend MIDI track for {}: {}", layer_name, e); eprintln!(" Layer created but will be silent until backend track is available"); } } } else { println!("âš ī¸ Audio engine not initialized - {} created but will be silent", layer_name); } } } MenuAction::AddTestClip => { // Create a test vector clip and add it to the library (not to timeline) use lightningbeam_core::clip::VectorClip; use lightningbeam_core::layer::{VectorLayer, AnyLayer}; use lightningbeam_core::shape::{Shape, ShapeColor}; use lightningbeam_core::object::ShapeInstance; use kurbo::{Circle, Rect, Shape as KurboShape}; // Generate unique name based on existing clip count let clip_count = self.action_executor.document().vector_clips.len(); let clip_name = format!("Test Clip {}", clip_count + 1); let mut test_clip = VectorClip::new(&clip_name, 400.0, 400.0, 5.0); // Create a layer with some shapes let mut layer = VectorLayer::new("Shapes"); // Create a red circle shape let circle_path = Circle::new((100.0, 100.0), 50.0).to_path(0.1); let mut circle_shape = Shape::new(circle_path); circle_shape.fill_color = Some(ShapeColor::rgb(255, 0, 0)); let circle_id = circle_shape.id; layer.add_shape(circle_shape); // Create a blue rectangle shape let rect_path = Rect::new(200.0, 50.0, 350.0, 150.0).to_path(0.1); let mut rect_shape = Shape::new(rect_path); rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255)); let rect_id = rect_shape.id; layer.add_shape(rect_shape); // Add shape instances layer.shape_instances.push(ShapeInstance::new(circle_id)); layer.shape_instances.push(ShapeInstance::new(rect_id)); // Add the layer to the clip test_clip.layers.add_root(AnyLayer::Vector(layer)); // Add to document's clip library only (user drags from Asset Library to timeline) let _clip_id = self.action_executor.document_mut().add_vector_clip(test_clip); println!("Added '{}' to Asset Library (drag to timeline to use)", clip_name); } MenuAction::DeleteLayer => { println!("Menu: Delete Layer"); // TODO: Implement delete layer } MenuAction::ToggleLayerVisibility => { println!("Menu: Toggle Layer Visibility"); // TODO: Implement toggle layer visibility } // Timeline menu MenuAction::NewKeyframe => { println!("Menu: New Keyframe"); // TODO: Implement new keyframe } MenuAction::NewBlankKeyframe => { println!("Menu: New Blank Keyframe"); // TODO: Implement new blank keyframe } MenuAction::DeleteFrame => { println!("Menu: Delete Frame"); // TODO: Implement delete frame } MenuAction::DuplicateKeyframe => { println!("Menu: Duplicate Keyframe"); // TODO: Implement duplicate keyframe } MenuAction::AddKeyframeAtPlayhead => { println!("Menu: Add Keyframe at Playhead"); // TODO: Implement add keyframe at playhead } MenuAction::AddMotionTween => { println!("Menu: Add Motion Tween"); // TODO: Implement add motion tween } MenuAction::AddShapeTween => { println!("Menu: Add Shape Tween"); // TODO: Implement add shape tween } MenuAction::ReturnToStart => { println!("Menu: Return to Start"); // TODO: Implement return to start } MenuAction::Play => { println!("Menu: Play"); // TODO: Implement play/pause } // View menu MenuAction::ZoomIn => { self.pending_view_action = Some(MenuAction::ZoomIn); } MenuAction::ZoomOut => { self.pending_view_action = Some(MenuAction::ZoomOut); } MenuAction::ActualSize => { self.pending_view_action = Some(MenuAction::ActualSize); } MenuAction::RecenterView => { self.pending_view_action = Some(MenuAction::RecenterView); } MenuAction::NextLayout => { println!("Menu: Next Layout"); let next_index = (self.current_layout_index + 1) % self.layouts.len(); self.switch_layout(next_index); } MenuAction::PreviousLayout => { println!("Menu: Previous Layout"); let prev_index = if self.current_layout_index == 0 { self.layouts.len() - 1 } else { self.current_layout_index - 1 }; self.switch_layout(prev_index); } MenuAction::SwitchLayout(index) => { println!("Menu: Switch to Layout {}", index); if index < self.layouts.len() { self.switch_layout(index); } } // Help menu MenuAction::About => { println!("Menu: About"); // TODO: Implement about dialog } // Lightningbeam menu (macOS) MenuAction::Settings => { println!("Menu: Settings"); // TODO: Implement settings } MenuAction::CloseWindow => { println!("Menu: Close Window"); // TODO: Implement close window } } } /// Prepare document for saving by storing current UI layout fn prepare_document_for_save(&mut self) { let doc = self.action_executor.document_mut(); // Store current layout state doc.ui_layout = Some(self.current_layout.clone()); // Store base layout name for reference if self.current_layout_index < self.layouts.len() { doc.ui_layout_base = Some(self.layouts[self.current_layout_index].name.clone()); } } /// Save the current document to a .beam file fn save_to_file(&mut self, path: std::path::PathBuf) { println!("Saving to: {}", path.display()); if self.audio_controller.is_none() { eprintln!("❌ Audio system not initialized"); return; } // Prepare document for save (including layout) self.prepare_document_for_save(); // Create progress channel let (progress_tx, progress_rx) = std::sync::mpsc::channel(); // Clone document for background thread let document = self.action_executor.document().clone(); // Send save command to worker thread let command = FileCommand::Save { path: path.clone(), document, progress_tx, }; if let Err(e) = self.file_command_tx.send(command) { eprintln!("❌ Failed to send save command: {}", e); return; } // Store operation state self.file_operation = Some(FileOperation::Saving { path, progress_rx, }); } /// Load a document from a .beam file fn load_from_file(&mut self, path: std::path::PathBuf) { println!("Loading from: {}", path.display()); if self.audio_controller.is_none() { eprintln!("❌ Audio system not initialized"); return; } // Create progress channel let (progress_tx, progress_rx) = std::sync::mpsc::channel(); // Send load command to worker thread let command = FileCommand::Load { path: path.clone(), progress_tx, }; if let Err(e) = self.file_command_tx.send(command) { eprintln!("❌ Failed to send load command: {}", e); return; } // Store operation state self.file_operation = Some(FileOperation::Loading { path, progress_rx, }); } /// Update the "Open Recent" menu to reflect current config fn update_recent_files_menu(&mut self) { if let Some(menu_system) = &mut self.menu_system { let recent_files = self.config.get_recent_files(); menu_system.update_recent_files(&recent_files); } } /// Restore UI layout from loaded document fn restore_layout_from_document(&mut self) { let doc = self.action_executor.document(); // Restore saved layout if present if let Some(saved_layout) = &doc.ui_layout { self.current_layout = saved_layout.clone(); // Try to find matching base layout by name if let Some(base_name) = &doc.ui_layout_base { if let Some(index) = self.layouts.iter().position(|l| &l.name == base_name) { self.current_layout_index = index; } else { // Base layout not found (maybe renamed/removed), default to first self.current_layout_index = 0; } } println!("✅ Restored UI layout from save file"); } else { // No saved layout (old file format or new project) // Keep the default (first layout) self.current_layout_index = 0; self.current_layout = self.layouts[0].layout.clone(); println!("â„šī¸ No saved layout found, using default"); } // Clear existing pane instances so they rebuild with new layout self.pane_instances.clear(); } /// Apply loaded project data (called after successful load in background) fn apply_loaded_project(&mut self, loaded_project: lightningbeam_core::file_io::LoadedProject, path: std::path::PathBuf) { use lightningbeam_core::action::ActionExecutor; let apply_start = std::time::Instant::now(); eprintln!("📊 [APPLY] Starting apply_loaded_project() on UI thread..."); // Check for missing files if !loaded_project.missing_files.is_empty() { eprintln!("âš ī¸ {} missing files", loaded_project.missing_files.len()); for missing in &loaded_project.missing_files { eprintln!(" - {}", missing.original_path.display()); } // TODO Phase 5: Show recovery dialog } // Replace document let step1_start = std::time::Instant::now(); self.action_executor = ActionExecutor::new(loaded_project.document); eprintln!("📊 [APPLY] Step 1: Replace document took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0); // Restore UI layout from loaded document let step2_start = std::time::Instant::now(); self.restore_layout_from_document(); eprintln!("📊 [APPLY] Step 2: Restore UI layout took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0); // Load audio pool FIRST (before setting project, so clips can reference pool entries) let step3_start = std::time::Instant::now(); if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); let audio_pool_entries = loaded_project.audio_pool_entries; eprintln!("📊 [APPLY] Step 3: Starting audio pool load..."); if let Err(e) = controller.load_audio_pool(audio_pool_entries, &path) { eprintln!("❌ Failed to load audio pool: {}", e); return; } eprintln!("📊 [APPLY] Step 3: Load audio pool took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0); // Now set project (clips can now reference the loaded pool entries) let step4_start = std::time::Instant::now(); if let Err(e) = controller.set_project(loaded_project.audio_project) { eprintln!("❌ Failed to set project: {}", e); return; } eprintln!("📊 [APPLY] Step 4: Set audio project took {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0); } // Reset state let step5_start = std::time::Instant::now(); self.layer_to_track_map.clear(); self.track_to_layer_map.clear(); eprintln!("📊 [APPLY] Step 5: Clear track maps took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0); // Sync audio layers (MIDI and Sampled) let step6_start = std::time::Instant::now(); self.sync_audio_layers_to_backend(); eprintln!("📊 [APPLY] Step 6: Sync audio layers took {:.2}ms", step6_start.elapsed().as_secs_f64() * 1000.0); // Fetch waveforms for all audio clips in the loaded project let step7_start = std::time::Instant::now(); // Collect pool indices first to avoid borrowing issues let pool_indices: Vec = self.action_executor.document() .audio_clips.values() .filter_map(|clip| { if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { Some(*audio_pool_index) } else { None } }) .collect(); let mut waveforms_fetched = 0; for pool_index in pool_indices { if self.fetch_waveform(pool_index).is_some() { waveforms_fetched += 1; } } eprintln!("📊 [APPLY] Step 7: Fetched {} waveforms in {:.2}ms", waveforms_fetched, step7_start.elapsed().as_secs_f64() * 1000.0); // Reset playback state self.playback_time = 0.0; self.is_playing = false; self.current_file_path = Some(path.clone()); // Add to recent files self.config.add_recent_file(path.clone()); self.update_recent_files_menu(); // Set active layer if let Some(first) = self.action_executor.document().root.children.first() { self.active_layer_id = Some(first.id()); } eprintln!("📊 [APPLY] ✅ Total apply_loaded_project() time: {:.2}ms", apply_start.elapsed().as_secs_f64() * 1000.0); println!("✅ Loaded from: {}", path.display()); } /// Import an image file as an ImageAsset fn import_image(&mut self, path: &std::path::Path) { use lightningbeam_core::clip::ImageAsset; // Get filename for asset name let name = path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("Untitled Image") .to_string(); // Load image to get dimensions match image::open(path) { Ok(img) => { let (width, height) = (img.width(), img.height()); // Read raw file data for embedding let data = match std::fs::read(path) { Ok(data) => Some(data), Err(e) => { eprintln!("Warning: Could not embed image data: {}", e); None } }; // Create image asset let mut asset = ImageAsset::new(&name, path, width, height); asset.data = data; // Add to document let asset_id = self.action_executor.document_mut().add_image_asset(asset); println!("Imported image '{}' ({}x{}) - ID: {}", name, width, height, asset_id); } Err(e) => { eprintln!("Failed to load image '{}': {}", path.display(), e); } } } /// Import an audio file via daw-backend fn import_audio(&mut self, path: &std::path::Path) { use daw_backend::io::audio_file::AudioFile; use lightningbeam_core::clip::{AudioClip, AudioClipType}; let name = path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("Untitled Audio") .to_string(); // Load audio file via daw-backend match AudioFile::load(path) { Ok(audio_file) => { let duration = audio_file.frames as f64 / audio_file.sample_rate as f64; let channels = audio_file.channels; let sample_rate = audio_file.sample_rate; // Add to audio engine pool if available if let Some(ref controller_arc) = self.audio_controller { let pool_index = { let mut controller = controller_arc.lock().unwrap(); // Send audio data to the engine let path_str = path.to_string_lossy().to_string(); controller.add_audio_file( path_str.clone(), audio_file.data, channels, sample_rate, ); // For now, use a placeholder pool index (the engine will assign the real one) // In a full implementation, we'd wait for the AudioFileAdded event self.action_executor.document().audio_clips.len() }; // Controller lock is dropped here // Create audio clip in document let clip = AudioClip::new_sampled(&name, pool_index, duration); let clip_id = self.action_executor.document_mut().add_audio_clip(clip); // Fetch waveform from backend and cache it for rendering if let Some(waveform) = self.fetch_waveform(pool_index) { println!("✅ Cached waveform with {} peaks", waveform.len()); } println!("Imported audio '{}' ({:.1}s, {}ch, {}Hz) - ID: {}", name, duration, channels, sample_rate, clip_id); } else { eprintln!("Cannot import audio: audio engine not initialized"); } } Err(e) => { eprintln!("Failed to load audio '{}': {}", path.display(), e); } } } /// Import a MIDI file via daw-backend fn import_midi(&mut self, path: &std::path::Path) { use lightningbeam_core::clip::AudioClip; let name = path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("Untitled MIDI") .to_string(); // Load MIDI file via daw-backend match daw_backend::io::midi_file::load_midi_file(path, 0, 44100) { Ok(midi_clip) => { let duration = midi_clip.duration; let event_count = midi_clip.events.len(); // Process MIDI events to cache format: (timestamp, note_number, is_note_on) // Filter to note events only (status 0x90 = note-on, 0x80 = note-off) let processed_events: Vec<(f64, u8, bool)> = midi_clip.events.iter() .filter_map(|event| { let status_type = event.status & 0xF0; if status_type == 0x90 || status_type == 0x80 { // Note-on is 0x90 with velocity > 0, Note-off is 0x80 or velocity = 0 let is_note_on = status_type == 0x90 && event.data2 > 0; Some((event.timestamp, event.data1, is_note_on)) } else { None // Ignore non-note events (CC, pitch bend, etc.) } }) .collect(); let note_event_count = processed_events.len(); // Add to backend MIDI clip pool FIRST and get the backend clip ID if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); controller.add_midi_clip_to_pool(midi_clip.clone()); let backend_clip_id = midi_clip.id; // The backend clip ID // Cache MIDI events in frontend for rendering (thumbnails & timeline piano roll) self.midi_event_cache.insert(backend_clip_id, processed_events); // Create frontend MIDI clip referencing the backend pool let clip = AudioClip::new_midi(&name, backend_clip_id, duration); let frontend_clip_id = self.action_executor.document_mut().add_audio_clip(clip); println!("Imported MIDI '{}' ({:.1}s, {} total events, {} note events) - Frontend ID: {}, Backend ID: {}", name, duration, event_count, note_event_count, frontend_clip_id, backend_clip_id); println!("✅ Added MIDI clip to backend pool and cached {} note events", note_event_count); } else { eprintln!("âš ī¸ Cannot import MIDI: audio system not available"); } } Err(e) => { eprintln!("Failed to load MIDI '{}': {}", path.display(), e); } } } /// Import a video file (placeholder - decoder not yet ported) fn import_video(&mut self, path: &std::path::Path) { use lightningbeam_core::clip::VideoClip; let name = path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("Untitled Video") .to_string(); // TODO: Use video decoder to get actual dimensions/duration // For now, create a placeholder with default values let clip = VideoClip::new( &name, path.to_string_lossy().to_string(), 1920.0, // Default width (TODO: probe video) 1080.0, // Default height (TODO: probe video) 0.0, // Duration unknown (TODO: probe video) 30.0, // Default frame rate (TODO: probe video) ); let clip_id = self.action_executor.document_mut().add_video_clip(clip); println!("Imported video '{}' (placeholder - dimensions/duration unknown) - ID: {}", name, clip_id); println!("Note: Video decoder not yet ported. Video preview unavailable."); } } impl eframe::App for EditorApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Disable egui's built-in Ctrl+Plus/Minus zoom behavior // We handle zoom ourselves for the Stage pane ctx.options_mut(|o| { o.zoom_with_keyboard = false; }); // Check for native menu events (macOS) if let Some(menu_system) = &self.menu_system { if let Some(action) = menu_system.check_events() { self.handle_menu_action(action); } } // Fetch missing waveforms on-demand (for lazy loading after project load) // Collect pool indices that need waveforms let missing_waveforms: Vec = self.action_executor.document() .audio_clips.values() .filter_map(|clip| { if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { // Check if not already cached if !self.waveform_cache.contains_key(audio_pool_index) { Some(*audio_pool_index) } else { None } } else { None } }) .collect(); // Fetch missing waveforms for pool_index in missing_waveforms { self.fetch_waveform(pool_index); } // Handle file operation progress if let Some(ref mut operation) = self.file_operation { // Set wait cursor ctx.set_cursor_icon(egui::CursorIcon::Progress); // Poll for progress updates let mut operation_complete = false; let mut loaded_project_data: Option<(lightningbeam_core::file_io::LoadedProject, std::path::PathBuf)> = None; let mut update_recent_menu = false; // Track if we need to update recent files menu match operation { FileOperation::Saving { ref mut progress_rx, ref path } => { while let Ok(progress) = progress_rx.try_recv() { match progress { FileProgress::Done => { println!("✅ Save complete!"); self.current_file_path = Some(path.clone()); // Add to recent files self.config.add_recent_file(path.clone()); update_recent_menu = true; operation_complete = true; } FileProgress::Error(e) => { eprintln!("❌ Save error: {}", e); operation_complete = true; } _ => { // Other progress states - just keep going } } } // Render progress dialog egui::Window::new("Saving Project") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) .show(ctx, |ui| { ui.vertical_centered(|ui| { ui.add(egui::Spinner::new()); ui.add_space(8.0); ui.label("Saving project..."); ui.label(format!("Path: {}", path.display())); }); }); } FileOperation::Loading { ref mut progress_rx, ref path } => { while let Ok(progress) = progress_rx.try_recv() { match progress { FileProgress::Complete(Ok(loaded_project)) => { println!("✅ Load complete!"); // Store data to apply after dialog closes loaded_project_data = Some((loaded_project, path.clone())); operation_complete = true; } FileProgress::Complete(Err(e)) => { eprintln!("❌ Load error: {}", e); operation_complete = true; } FileProgress::Error(e) => { eprintln!("❌ Load error: {}", e); operation_complete = true; } _ => { // Other progress states - just keep going } } } // Render progress dialog egui::Window::new("Loading Project") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) .show(ctx, |ui| { ui.vertical_centered(|ui| { ui.add(egui::Spinner::new()); ui.add_space(8.0); ui.label("Loading project..."); ui.label(format!("Path: {}", path.display())); }); }); } } // Clear operation if complete if operation_complete { self.file_operation = None; } // Apply loaded project data if available if let Some((loaded_project, path)) = loaded_project_data { self.apply_loaded_project(loaded_project, path); } // Update recent files menu if needed if update_recent_menu { self.update_recent_files_menu(); } // Request repaint to keep updating progress ctx.request_repaint(); } // Poll audio events from the audio engine if let Some(event_rx) = &mut self.audio_event_rx { while let Ok(event) = event_rx.pop() { use daw_backend::AudioEvent; match event { AudioEvent::PlaybackPosition(time) => { self.playback_time = time; } AudioEvent::PlaybackStopped => { self.is_playing = false; } _ => {} // Ignore other events for now } } } // Request continuous repaints when playing to update time display if self.is_playing { ctx.request_repaint(); } // Top menu bar (egui-rendered on all platforms) 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) { self.handle_menu_action(action); } } }); // Main pane area let mut layout_action: Option = None; egui::CentralPanel::default().show(ctx, |ui| { let available_rect = ui.available_rect_before_wrap(); // Reset hovered divider each frame self.hovered_divider = None; // Track fallback pane priority for view actions (reset each frame) let mut fallback_pane_priority: Option = None; // Registry for view action handlers (two-phase dispatch) let mut pending_handlers: Vec = Vec::new(); // Registry for actions to execute after rendering (two-phase dispatch) let mut pending_actions: Vec> = Vec::new(); // Create render context let mut ctx = RenderContext { tool_icon_cache: &mut self.tool_icon_cache, icon_cache: &mut self.icon_cache, selected_tool: &mut self.selected_tool, fill_color: &mut self.fill_color, stroke_color: &mut self.stroke_color, active_color_mode: &mut self.active_color_mode, pane_instances: &mut self.pane_instances, pending_view_action: &mut self.pending_view_action, fallback_pane_priority: &mut fallback_pane_priority, pending_handlers: &mut pending_handlers, theme: &self.theme, action_executor: &mut self.action_executor, selection: &mut self.selection, active_layer_id: &mut self.active_layer_id, tool_state: &mut self.tool_state, pending_actions: &mut pending_actions, draw_simplify_mode: &mut self.draw_simplify_mode, rdp_tolerance: &mut self.rdp_tolerance, schneider_max_error: &mut self.schneider_max_error, audio_controller: self.audio_controller.as_ref(), playback_time: &mut self.playback_time, is_playing: &mut self.is_playing, dragging_asset: &mut self.dragging_asset, stroke_width: &mut self.stroke_width, fill_enabled: &mut self.fill_enabled, paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, polygon_sides: &mut self.polygon_sides, layer_to_track_map: &self.layer_to_track_map, midi_event_cache: &self.midi_event_cache, waveform_cache: &self.waveform_cache, waveform_image_cache: &mut self.waveform_image_cache, }; render_layout_node( ui, &mut self.current_layout, available_rect, &mut self.drag_state, &mut self.hovered_divider, &mut self.selected_pane, &mut layout_action, &mut self.split_preview_mode, &Vec::new(), // Root path &mut ctx, ); // Execute action on the best handler (two-phase dispatch) if let Some(action) = &self.pending_view_action { if let Some(best_handler) = pending_handlers.iter().min_by_key(|h| h.priority) { // Look up the pane instance and execute the action if let Some(pane_instance) = self.pane_instances.get_mut(&best_handler.pane_path) { match pane_instance { panes::PaneInstance::Stage(stage_pane) => { stage_pane.execute_view_action(action, best_handler.zoom_center); } _ => {} // Other pane types don't handle view actions yet } } } // Clear the pending action after execution self.pending_view_action = None; } // Execute all pending actions (two-phase dispatch) for action in pending_actions { // Create backend context for actions that need backend sync if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, }; // Execute action with backend synchronization if let Err(e) = self.action_executor.execute_with_backend(action, &mut backend_context) { eprintln!("Action execution failed: {}", e); } } else { // No audio system available, execute without backend self.action_executor.execute(action); } } // Set cursor based on hover state if let Some((_, is_horizontal)) = self.hovered_divider { if is_horizontal { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } else { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } } }); // Handle ESC key and click-outside to cancel split preview if let SplitPreviewMode::Active { hovered_pane, .. } = &self.split_preview_mode { let should_cancel = ctx.input(|i| { // Cancel on ESC key if i.key_pressed(egui::Key::Escape) { return true; } // Cancel on click outside any pane if i.pointer.primary_clicked() && hovered_pane.is_none() { return true; } false }); if should_cancel { self.split_preview_mode = SplitPreviewMode::None; } } // Apply layout action after rendering to avoid borrow issues if let Some(action) = layout_action { self.apply_layout_action(action); } // Check keyboard shortcuts AFTER UI is rendered // This ensures text fields have had a chance to claim focus first let wants_keyboard = ctx.wants_keyboard_input(); ctx.input(|i| { // 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) { // 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 { use lightningbeam_core::tool::Tool; 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; } } }); } } /// Context for rendering operations - bundles all mutable state needed during rendering /// This avoids having 25+ individual parameters in rendering functions struct RenderContext<'a> { tool_icon_cache: &'a mut ToolIconCache, icon_cache: &'a mut IconCache, selected_tool: &'a mut Tool, fill_color: &'a mut egui::Color32, stroke_color: &'a mut egui::Color32, active_color_mode: &'a mut panes::ColorMode, pane_instances: &'a mut HashMap, pending_view_action: &'a mut Option, fallback_pane_priority: &'a mut Option, pending_handlers: &'a mut Vec, theme: &'a Theme, action_executor: &'a mut lightningbeam_core::action::ActionExecutor, selection: &'a mut lightningbeam_core::selection::Selection, active_layer_id: &'a mut Option, tool_state: &'a mut lightningbeam_core::tool::ToolState, pending_actions: &'a mut Vec>, draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode, rdp_tolerance: &'a mut f64, schneider_max_error: &'a mut f64, audio_controller: Option<&'a std::sync::Arc>>, playback_time: &'a mut f64, is_playing: &'a mut bool, dragging_asset: &'a mut Option, // Tool-specific options for infopanel stroke_width: &'a mut f64, fill_enabled: &'a mut bool, paint_bucket_gap_tolerance: &'a mut f64, polygon_sides: &'a mut u32, /// Mapping from Document layer UUIDs to daw-backend TrackIds layer_to_track_map: &'a std::collections::HashMap, /// Cache of MIDI events for rendering (keyed by backend midi_clip_id) midi_event_cache: &'a HashMap>, /// Cache of waveform data for rendering (keyed by audio_pool_index) waveform_cache: &'a HashMap>, /// Cache of rendered waveform images (GPU textures) waveform_image_cache: &'a mut waveform_image_cache::WaveformImageCache, } /// Recursively render a layout node with drag support fn render_layout_node( ui: &mut egui::Ui, node: &mut LayoutNode, rect: egui::Rect, drag_state: &mut DragState, hovered_divider: &mut Option<(NodePath, bool)>, selected_pane: &mut Option, layout_action: &mut Option, split_preview_mode: &mut SplitPreviewMode, path: &NodePath, ctx: &mut RenderContext, ) { match node { LayoutNode::Pane { name } => { render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, path, ctx); } LayoutNode::HorizontalGrid { percent, children } => { // Handle dragging if drag_state.is_dragging && drag_state.node_path == *path { if let Some(pointer_pos) = ui.input(|i| i.pointer.interact_pos()) { // Calculate new percentage based on pointer position let new_percent = ((pointer_pos.x - rect.left()) / rect.width() * 100.0) .clamp(10.0, 90.0); // Clamp to prevent too small panes *percent = new_percent; } } // Split horizontally (left | right) let split_x = rect.left() + (rect.width() * *percent / 100.0); let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(split_x, rect.max.y)); let right_rect = egui::Rect::from_min_max(egui::pos2(split_x, rect.min.y), rect.max); // Render children let mut left_path = path.clone(); left_path.push(0); render_layout_node( ui, &mut children[0], left_rect, drag_state, hovered_divider, selected_pane, layout_action, split_preview_mode, &left_path, ctx, ); let mut right_path = path.clone(); right_path.push(1); render_layout_node( ui, &mut children[1], right_rect, drag_state, hovered_divider, selected_pane, layout_action, split_preview_mode, &right_path, ctx, ); // Draw divider with interaction let divider_width = 8.0; let divider_rect = egui::Rect::from_min_max( egui::pos2(split_x - divider_width / 2.0, rect.min.y), egui::pos2(split_x + divider_width / 2.0, rect.max.y), ); let divider_id = ui.id().with(("divider", path)); let response = ui.interact(divider_rect, divider_id, egui::Sense::click_and_drag()); // Check if pointer is over divider if response.hovered() { *hovered_divider = Some((path.clone(), true)); } // Handle drag start if response.drag_started() { drag_state.is_dragging = true; drag_state.node_path = path.clone(); drag_state.is_horizontal = true; } // Handle drag end if response.drag_stopped() { drag_state.is_dragging = false; } // Context menu on right-click response.context_menu(|ui| { ui.set_min_width(180.0); if ui.button("Split Horizontal ->").clicked() { *layout_action = Some(LayoutAction::EnterSplitPreviewHorizontal); ui.close_menu(); } if ui.button("Split Vertical |").clicked() { *layout_action = Some(LayoutAction::EnterSplitPreviewVertical); ui.close_menu(); } ui.separator(); if ui.button("< Join Left").clicked() { let mut path_keep_right = path.clone(); path_keep_right.push(1); // Remove left, keep right child *layout_action = Some(LayoutAction::RemoveSplit(path_keep_right)); ui.close_menu(); } if ui.button("Join Right >").clicked() { let mut path_keep_left = path.clone(); path_keep_left.push(0); // Remove right, keep left child *layout_action = Some(LayoutAction::RemoveSplit(path_keep_left)); ui.close_menu(); } }); // Visual feedback let divider_color = if response.hovered() || response.dragged() { egui::Color32::from_gray(120) } else { egui::Color32::from_gray(60) }; ui.painter().vline( split_x, rect.y_range(), egui::Stroke::new(2.0, divider_color), ); } LayoutNode::VerticalGrid { percent, children } => { // Handle dragging if drag_state.is_dragging && drag_state.node_path == *path { if let Some(pointer_pos) = ui.input(|i| i.pointer.interact_pos()) { // Calculate new percentage based on pointer position let new_percent = ((pointer_pos.y - rect.top()) / rect.height() * 100.0) .clamp(10.0, 90.0); // Clamp to prevent too small panes *percent = new_percent; } } // Split vertically (top / bottom) let split_y = rect.top() + (rect.height() * *percent / 100.0); let top_rect = egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x, split_y)); let bottom_rect = egui::Rect::from_min_max(egui::pos2(rect.min.x, split_y), rect.max); // Render children let mut top_path = path.clone(); top_path.push(0); render_layout_node( ui, &mut children[0], top_rect, drag_state, hovered_divider, selected_pane, layout_action, split_preview_mode, &top_path, ctx, ); let mut bottom_path = path.clone(); bottom_path.push(1); render_layout_node( ui, &mut children[1], bottom_rect, drag_state, hovered_divider, selected_pane, layout_action, split_preview_mode, &bottom_path, ctx, ); // Draw divider with interaction let divider_height = 8.0; let divider_rect = egui::Rect::from_min_max( egui::pos2(rect.min.x, split_y - divider_height / 2.0), egui::pos2(rect.max.x, split_y + divider_height / 2.0), ); let divider_id = ui.id().with(("divider", path)); let response = ui.interact(divider_rect, divider_id, egui::Sense::click_and_drag()); // Check if pointer is over divider if response.hovered() { *hovered_divider = Some((path.clone(), false)); } // Handle drag start if response.drag_started() { drag_state.is_dragging = true; drag_state.node_path = path.clone(); drag_state.is_horizontal = false; } // Handle drag end if response.drag_stopped() { drag_state.is_dragging = false; } // Context menu on right-click response.context_menu(|ui| { ui.set_min_width(180.0); if ui.button("Split Horizontal ->").clicked() { *layout_action = Some(LayoutAction::EnterSplitPreviewHorizontal); ui.close_menu(); } if ui.button("Split Vertical |").clicked() { *layout_action = Some(LayoutAction::EnterSplitPreviewVertical); ui.close_menu(); } ui.separator(); if ui.button("^ Join Up").clicked() { let mut path_keep_bottom = path.clone(); path_keep_bottom.push(1); // Remove top, keep bottom child *layout_action = Some(LayoutAction::RemoveSplit(path_keep_bottom)); ui.close_menu(); } if ui.button("Join Down v").clicked() { let mut path_keep_top = path.clone(); path_keep_top.push(0); // Remove bottom, keep top child *layout_action = Some(LayoutAction::RemoveSplit(path_keep_top)); ui.close_menu(); } }); // Visual feedback let divider_color = if response.hovered() || response.dragged() { egui::Color32::from_gray(120) } else { egui::Color32::from_gray(60) }; ui.painter().hline( rect.x_range(), split_y, egui::Stroke::new(2.0, divider_color), ); } } } /// Render a single pane with its content fn render_pane( ui: &mut egui::Ui, pane_name: &mut String, rect: egui::Rect, selected_pane: &mut Option, layout_action: &mut Option, split_preview_mode: &mut SplitPreviewMode, path: &NodePath, ctx: &mut RenderContext, ) { let pane_type = PaneType::from_name(pane_name); // Define header and content areas let header_height = 40.0; let header_rect = egui::Rect::from_min_size( rect.min, egui::vec2(rect.width(), header_height), ); let content_rect = egui::Rect::from_min_size( rect.min + egui::vec2(0.0, header_height), egui::vec2(rect.width(), rect.height() - header_height), ); // Draw header background ui.painter().rect_filled( header_rect, 0.0, egui::Color32::from_rgb(35, 35, 35), ); // Draw content background let bg_color = if let Some(pane_type) = pane_type { pane_color(pane_type) } else { egui::Color32::from_rgb(40, 40, 40) }; ui.painter().rect_filled(content_rect, 0.0, bg_color); // Draw border around entire pane let border_color = egui::Color32::from_gray(80); let border_width = 1.0; ui.painter().rect_stroke( rect, 0.0, egui::Stroke::new(border_width, border_color), egui::StrokeKind::Middle, ); // Draw header separator line ui.painter().hline( rect.x_range(), header_rect.max.y, egui::Stroke::new(1.0, egui::Color32::from_gray(50)), ); // Render icon button in header (left side) let icon_size = 24.0; let icon_padding = 8.0; let icon_button_rect = egui::Rect::from_min_size( header_rect.min + egui::vec2(icon_padding, icon_padding), egui::vec2(icon_size, icon_size), ); // Draw icon button background ui.painter().rect_filled( icon_button_rect, 4.0, egui::Color32::from_rgba_premultiplied(50, 50, 50, 200), ); // Load and render icon if available if let Some(pane_type) = pane_type { if let Some(icon) = ctx.icon_cache.get_or_load(pane_type) { let icon_texture_id = icon.texture_id(ui.ctx()); let icon_rect = icon_button_rect.shrink(2.0); // Small padding inside button ui.painter().image( icon_texture_id, icon_rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } } // Make icon button interactive (show pane type menu on click) let icon_button_id = ui.id().with(("icon_button", path)); let icon_response = ui.interact(icon_button_rect, icon_button_id, egui::Sense::click()); if icon_response.hovered() { ui.painter().rect_stroke( icon_button_rect, 4.0, egui::Stroke::new(1.0, egui::Color32::from_gray(180)), egui::StrokeKind::Middle, ); } // Show pane type selector menu on left click let menu_id = ui.id().with(("pane_type_menu", path)); if icon_response.clicked() { ui.memory_mut(|mem| mem.toggle_popup(menu_id)); } egui::popup::popup_below_widget(ui, menu_id, &icon_response, egui::PopupCloseBehavior::CloseOnClickOutside, |ui| { ui.set_min_width(200.0); ui.label("Select Pane Type:"); ui.separator(); for pane_type_option in PaneType::all() { // Load icon for this pane type if let Some(icon) = ctx.icon_cache.get_or_load(*pane_type_option) { ui.horizontal(|ui| { // Show icon let icon_texture_id = icon.texture_id(ui.ctx()); let icon_size = egui::vec2(16.0, 16.0); ui.add(egui::Image::new((icon_texture_id, icon_size))); // Show label with selection if ui.selectable_label( pane_type == Some(*pane_type_option), pane_type_option.display_name() ).clicked() { *pane_name = pane_type_option.to_name().to_string(); ui.memory_mut(|mem| mem.close_popup()); } }); } else { // Fallback if icon fails to load if ui.selectable_label( pane_type == Some(*pane_type_option), pane_type_option.display_name() ).clicked() { *pane_name = pane_type_option.to_name().to_string(); ui.memory_mut(|mem| mem.close_popup()); } } } }); // Draw pane title in header let title_text = if let Some(pane_type) = pane_type { pane_type.display_name() } else { pane_name.as_str() }; let title_pos = header_rect.min + egui::vec2(icon_padding * 2.0 + icon_size + 8.0, header_height / 2.0); ui.painter().text( title_pos, egui::Align2::LEFT_CENTER, title_text, egui::FontId::proportional(14.0), egui::Color32::from_gray(220), ); // Create header controls area (positioned after title) let title_width = 150.0; // Approximate width for title let header_controls_rect = egui::Rect::from_min_size( header_rect.min + egui::vec2(icon_padding * 2.0 + icon_size + 8.0 + title_width, 0.0), egui::vec2(header_rect.width() - (icon_padding * 2.0 + icon_size + 8.0 + title_width), header_height), ); // Render pane-specific header controls (if pane has them) if let Some(pane_type) = pane_type { // Get or create pane instance for header rendering let needs_new_instance = ctx.pane_instances .get(path) .map(|instance| instance.pane_type() != pane_type) .unwrap_or(true); if needs_new_instance { ctx.pane_instances.insert(path.clone(), panes::PaneInstance::new(pane_type)); } if let Some(pane_instance) = ctx.pane_instances.get_mut(path) { let mut header_ui = ui.new_child(egui::UiBuilder::new().max_rect(header_controls_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut shared = panes::SharedPaneState { tool_icon_cache: ctx.tool_icon_cache, icon_cache: ctx.icon_cache, selected_tool: ctx.selected_tool, fill_color: ctx.fill_color, stroke_color: ctx.stroke_color, active_color_mode: ctx.active_color_mode, pending_view_action: ctx.pending_view_action, fallback_pane_priority: ctx.fallback_pane_priority, theme: ctx.theme, pending_handlers: ctx.pending_handlers, action_executor: ctx.action_executor, selection: ctx.selection, active_layer_id: ctx.active_layer_id, tool_state: ctx.tool_state, pending_actions: ctx.pending_actions, draw_simplify_mode: ctx.draw_simplify_mode, rdp_tolerance: ctx.rdp_tolerance, schneider_max_error: ctx.schneider_max_error, audio_controller: ctx.audio_controller, layer_to_track_map: ctx.layer_to_track_map, playback_time: ctx.playback_time, is_playing: ctx.is_playing, dragging_asset: ctx.dragging_asset, stroke_width: ctx.stroke_width, fill_enabled: ctx.fill_enabled, paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance, polygon_sides: ctx.polygon_sides, midi_event_cache: ctx.midi_event_cache, waveform_cache: ctx.waveform_cache, waveform_image_cache: ctx.waveform_image_cache, }; pane_instance.render_header(&mut header_ui, &mut shared); } } // Make pane content clickable (use content rect, not header, for split preview interaction) let pane_id = ui.id().with(("pane", path)); let response = ui.interact(content_rect, pane_id, egui::Sense::click()); // Render pane-specific content using trait-based system if let Some(pane_type) = pane_type { // Get or create pane instance for this path // Check if we need a new instance (either doesn't exist or type changed) let needs_new_instance = ctx.pane_instances .get(path) .map(|instance| instance.pane_type() != pane_type) .unwrap_or(true); if needs_new_instance { ctx.pane_instances.insert(path.clone(), PaneInstance::new(pane_type)); } // Get the pane instance and render its content if let Some(pane_instance) = ctx.pane_instances.get_mut(path) { // Create shared state let mut shared = SharedPaneState { tool_icon_cache: ctx.tool_icon_cache, icon_cache: ctx.icon_cache, selected_tool: ctx.selected_tool, fill_color: ctx.fill_color, stroke_color: ctx.stroke_color, active_color_mode: ctx.active_color_mode, pending_view_action: ctx.pending_view_action, fallback_pane_priority: ctx.fallback_pane_priority, theme: ctx.theme, pending_handlers: ctx.pending_handlers, action_executor: ctx.action_executor, selection: ctx.selection, active_layer_id: ctx.active_layer_id, tool_state: ctx.tool_state, pending_actions: ctx.pending_actions, draw_simplify_mode: ctx.draw_simplify_mode, rdp_tolerance: ctx.rdp_tolerance, schneider_max_error: ctx.schneider_max_error, audio_controller: ctx.audio_controller, layer_to_track_map: ctx.layer_to_track_map, playback_time: ctx.playback_time, is_playing: ctx.is_playing, dragging_asset: ctx.dragging_asset, stroke_width: ctx.stroke_width, fill_enabled: ctx.fill_enabled, paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance, polygon_sides: ctx.polygon_sides, midi_event_cache: ctx.midi_event_cache, waveform_cache: ctx.waveform_cache, waveform_image_cache: ctx.waveform_image_cache, }; // Render pane content (header was already rendered above) pane_instance.render_content(ui, content_rect, path, &mut shared); } } else { // Unknown pane type - draw placeholder let content_text = "Unknown pane type"; let text_pos = content_rect.center(); ui.painter().text( text_pos, egui::Align2::CENTER_CENTER, content_text, egui::FontId::proportional(16.0), egui::Color32::from_gray(150), ); } // Handle split preview mode (rendered AFTER pane content for proper z-ordering) if let SplitPreviewMode::Active { is_horizontal, hovered_pane, split_percent, } = split_preview_mode { // Check if mouse is over this pane if let Some(pointer_pos) = ui.input(|i| i.pointer.hover_pos()) { if rect.contains(pointer_pos) { // Update hovered pane *hovered_pane = Some(path.clone()); // Calculate split percentage based on mouse position *split_percent = if *is_horizontal { ((pointer_pos.x - rect.left()) / rect.width() * 100.0).clamp(10.0, 90.0) } else { ((pointer_pos.y - rect.top()) / rect.height() * 100.0).clamp(10.0, 90.0) }; // Render split preview overlay let grey_overlay = egui::Color32::from_rgba_premultiplied(128, 128, 128, 30); if *is_horizontal { let split_x = rect.left() + (rect.width() * *split_percent / 100.0); // First half let first_rect = egui::Rect::from_min_max( rect.min, egui::pos2(split_x, rect.max.y), ); ui.painter().rect_filled(first_rect, 0.0, grey_overlay); // Second half let second_rect = egui::Rect::from_min_max( egui::pos2(split_x, rect.min.y), rect.max, ); ui.painter().rect_filled(second_rect, 0.0, grey_overlay); // Divider line ui.painter().vline( split_x, rect.y_range(), egui::Stroke::new(2.0, egui::Color32::BLACK), ); } else { let split_y = rect.top() + (rect.height() * *split_percent / 100.0); // First half let first_rect = egui::Rect::from_min_max( rect.min, egui::pos2(rect.max.x, split_y), ); ui.painter().rect_filled(first_rect, 0.0, grey_overlay); // Second half let second_rect = egui::Rect::from_min_max( egui::pos2(rect.min.x, split_y), rect.max, ); ui.painter().rect_filled(second_rect, 0.0, grey_overlay); // Divider line ui.painter().hline( rect.x_range(), split_y, egui::Stroke::new(2.0, egui::Color32::BLACK), ); } // Create a high-priority interaction for split preview (rendered last = highest priority) let split_preview_id = ui.id().with(("split_preview", path)); let split_response = ui.interact(rect, split_preview_id, egui::Sense::click()); // If clicked, perform the split if split_response.clicked() { if *is_horizontal { *layout_action = Some(LayoutAction::SplitHorizontal(path.clone(), *split_percent)); } else { *layout_action = Some(LayoutAction::SplitVertical(path.clone(), *split_percent)); } // Exit preview mode *split_preview_mode = SplitPreviewMode::None; } } } } else if response.clicked() { *selected_pane = Some(path.clone()); } } /// Render toolbar with tool buttons fn render_toolbar( ui: &mut egui::Ui, rect: egui::Rect, tool_icon_cache: &mut ToolIconCache, selected_tool: &mut Tool, path: &NodePath, ) { let button_size = 60.0; // 50% bigger (was 40.0) let button_padding = 8.0; let button_spacing = 4.0; // Calculate how many columns we can fit let available_width = rect.width() - (button_padding * 2.0); let columns = ((available_width + button_spacing) / (button_size + button_spacing)).floor() as usize; let columns = columns.max(1); // At least 1 column let mut x = rect.left() + button_padding; let mut y = rect.top() + button_padding; let mut col = 0; for tool in Tool::all() { let button_rect = egui::Rect::from_min_size( egui::pos2(x, y), egui::vec2(button_size, button_size), ); // Check if this is the selected tool let is_selected = *selected_tool == *tool; // Button background let bg_color = if is_selected { egui::Color32::from_rgb(70, 100, 150) // Highlighted blue } else { egui::Color32::from_rgb(50, 50, 50) }; ui.painter().rect_filled(button_rect, 4.0, bg_color); // Load and render tool icon if let Some(icon) = tool_icon_cache.get_or_load(*tool, ui.ctx()) { let icon_rect = button_rect.shrink(8.0); // Padding inside button ui.painter().image( icon.id(), icon_rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } // Make button interactive (include path to ensure unique IDs across panes) let button_id = ui.id().with(("tool_button", path, *tool as usize)); let response = ui.interact(button_rect, button_id, egui::Sense::click()); // Check for click first if response.clicked() { *selected_tool = *tool; } if response.hovered() { ui.painter().rect_stroke( button_rect, 4.0, egui::Stroke::new(2.0, egui::Color32::from_gray(180)), egui::StrokeKind::Middle, ); } // Show tooltip with tool name and shortcut (consumes response) response.on_hover_text(format!("{} ({})", tool.display_name(), tool.shortcut_hint())); // Draw selection border if is_selected { ui.painter().rect_stroke( button_rect, 4.0, egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 150, 255)), egui::StrokeKind::Middle, ); } // Move to next position in grid col += 1; if col >= columns { // Move to next row col = 0; x = rect.left() + button_padding; y += button_size + button_spacing; } else { // Move to next column x += button_size + button_spacing; } } } /// Get a color for each pane type for visualization fn pane_color(pane_type: PaneType) -> egui::Color32 { match pane_type { PaneType::Stage => egui::Color32::from_rgb(30, 40, 50), PaneType::Timeline => egui::Color32::from_rgb(40, 30, 50), PaneType::Toolbar => egui::Color32::from_rgb(50, 40, 30), PaneType::Infopanel => egui::Color32::from_rgb(30, 50, 40), PaneType::Outliner => egui::Color32::from_rgb(40, 50, 30), PaneType::PianoRoll => egui::Color32::from_rgb(55, 35, 45), PaneType::VirtualPiano => egui::Color32::from_rgb(45, 35, 55), PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50), PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30), PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35), } } /// Split a pane node into a horizontal or vertical grid with two copies of the pane fn split_node(root: &mut LayoutNode, path: &NodePath, is_horizontal: bool, percent: f32) { if path.is_empty() { // Split the root node if let LayoutNode::Pane { name } = root { let pane_name = name.clone(); let new_node = if is_horizontal { LayoutNode::HorizontalGrid { percent, children: [ Box::new(LayoutNode::Pane { name: pane_name.clone() }), Box::new(LayoutNode::Pane { name: pane_name }), ], } } else { LayoutNode::VerticalGrid { percent, children: [ Box::new(LayoutNode::Pane { name: pane_name.clone() }), Box::new(LayoutNode::Pane { name: pane_name }), ], } }; *root = new_node; } } else { // Navigate to parent and split the child navigate_to_node(root, &path[..path.len() - 1], &mut |node| { let child_index = path[path.len() - 1]; match node { LayoutNode::HorizontalGrid { children, .. } | LayoutNode::VerticalGrid { children, .. } => { if let LayoutNode::Pane { name } = &*children[child_index] { let pane_name = name.clone(); let new_node = if is_horizontal { LayoutNode::HorizontalGrid { percent, children: [ Box::new(LayoutNode::Pane { name: pane_name.clone() }), Box::new(LayoutNode::Pane { name: pane_name }), ], } } else { LayoutNode::VerticalGrid { percent, children: [ Box::new(LayoutNode::Pane { name: pane_name.clone() }), Box::new(LayoutNode::Pane { name: pane_name }), ], } }; children[child_index] = Box::new(new_node); } } _ => {} } }); } } /// Remove a split by replacing it with one of its children /// The path includes the split node path plus which child to keep (0 or 1 as last element) fn remove_split(root: &mut LayoutNode, path: &NodePath) { if path.is_empty() { return; // Can't remove if path is empty } // Last element indicates which child to keep (0 or 1) let child_to_keep = path[path.len() - 1]; // Path to the split node is everything except the last element let split_path = &path[..path.len() - 1]; if split_path.is_empty() { // Removing root split - replace root with the chosen child if let LayoutNode::HorizontalGrid { children, .. } | LayoutNode::VerticalGrid { children, .. } = root { *root = (*children[child_to_keep]).clone(); } } else { // Navigate to parent of the split node and replace it let parent_path = &split_path[..split_path.len() - 1]; let split_index = split_path[split_path.len() - 1]; navigate_to_node(root, parent_path, &mut |node| { match node { LayoutNode::HorizontalGrid { children, .. } | LayoutNode::VerticalGrid { children, .. } => { // Get the split node's chosen child if let LayoutNode::HorizontalGrid { children: split_children, .. } | LayoutNode::VerticalGrid { children: split_children, .. } = &*children[split_index] { // Replace the split node with the chosen child children[split_index] = split_children[child_to_keep].clone(); } } _ => {} } }); } } /// Navigate to a node at the given path and apply a function to it fn navigate_to_node(node: &mut LayoutNode, path: &[usize], f: &mut F) where F: FnMut(&mut LayoutNode), { if path.is_empty() { f(node); } else { match node { LayoutNode::HorizontalGrid { children, .. } | LayoutNode::VerticalGrid { children, .. } => { navigate_to_node(&mut children[path[0]], &path[1..], f); } _ => {} } } }