diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index 790311b..8d8a8c0 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -8,7 +8,7 @@ serde = { workspace = true } serde_json = { workspace = true } # UI framework (for Color32 conversion) -egui = "0.31" +egui = { workspace = true } # GPU rendering infrastructure wgpu = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs index 42cca3d..7f13e4f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs @@ -147,6 +147,7 @@ impl SrgbToLinearConverter { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs index be8ab9d..d434101 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs @@ -355,6 +355,7 @@ impl Compositor { load: load_op, store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs index 3e8ff61..25cbeb3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs @@ -293,6 +293,7 @@ impl EffectProcessor { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, diff --git a/lightningbeam-ui/lightningbeam-core/src/pane.rs b/lightningbeam-ui/lightningbeam-core/src/pane.rs index 8ecf74c..ceb8f87 100644 --- a/lightningbeam-ui/lightningbeam-core/src/pane.rs +++ b/lightningbeam-ui/lightningbeam-core/src/pane.rs @@ -33,6 +33,8 @@ pub enum PaneType { PresetBrowser, /// Asset library for browsing clips AssetLibrary, + /// WGSL shader code editor for custom effects + ShaderEditor, } impl PaneType { @@ -49,6 +51,7 @@ impl PaneType { PaneType::NodeEditor => "Node Editor", PaneType::PresetBrowser => "Preset Browser", PaneType::AssetLibrary => "Asset Library", + PaneType::ShaderEditor => "Shader Editor", } } @@ -67,6 +70,7 @@ impl PaneType { PaneType::NodeEditor => "node-editor.svg", PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon PaneType::AssetLibrary => "stage.svg", // TODO: needs own icon + PaneType::ShaderEditor => "node-editor.svg", // TODO: needs own icon } } @@ -84,6 +88,7 @@ impl PaneType { "nodeeditor" => Some(PaneType::NodeEditor), "presetbrowser" => Some(PaneType::PresetBrowser), "assetlibrary" => Some(PaneType::AssetLibrary), + "shadereditor" => Some(PaneType::ShaderEditor), _ => None, } } @@ -101,6 +106,7 @@ impl PaneType { PaneType::VirtualPiano, PaneType::PresetBrowser, PaneType::AssetLibrary, + PaneType::ShaderEditor, ] } @@ -117,6 +123,7 @@ impl PaneType { PaneType::NodeEditor => "nodeEditor", PaneType::PresetBrowser => "presetBrowser", PaneType::AssetLibrary => "assetLibrary", + PaneType::ShaderEditor => "shaderEditor", } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 74ef493..17015d5 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -18,12 +18,12 @@ use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; use vello::kurbo::Rect; -use vello::peniko::{Blob, Fill, Image, ImageFormat}; +use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; use vello::Scene; /// Cache for decoded image data to avoid re-decoding every frame pub struct ImageCache { - cache: HashMap>, + cache: HashMap>, } impl ImageCache { @@ -35,7 +35,7 @@ impl ImageCache { } /// Get or decode an image, caching the result - pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option> { + pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option> { if let Some(cached) = self.cache.get(&asset.id) { return Some(Arc::clone(cached)); } @@ -64,8 +64,8 @@ impl Default for ImageCache { } } -/// Decode an image asset to peniko Image -fn decode_image_asset(asset: &ImageAsset) -> Option { +/// Decode an image asset to peniko ImageBrush +fn decode_image_asset(asset: &ImageAsset) -> Option { // Get the raw file data let data = asset.data.as_ref()?; @@ -73,13 +73,15 @@ fn decode_image_asset(asset: &ImageAsset) -> Option { let img = image::load_from_memory(data).ok()?; let rgba = img.to_rgba8(); - // Create peniko Image - Some(Image::new( - Blob::from(rgba.into_raw()), - ImageFormat::Rgba8, - asset.width, - asset.height, - )) + // Create peniko ImageData then ImageBrush + let image_data = ImageData { + data: Blob::from(rgba.into_raw()), + format: ImageFormat::Rgba8, + width: asset.width, + height: asset.height, + alpha_type: ImageAlphaType::Alpha, + }; + Some(ImageBrush::new(image_data)) } // ============================================================================ @@ -720,15 +722,17 @@ fn render_video_layer( // Cascade opacity: layer_opacity × animated opacity let final_opacity = (layer_opacity * opacity) as f32; - // Create peniko Image from video frame data (zero-copy via Arc clone) + // Create peniko ImageBrush from video frame data (zero-copy via Arc clone) // Coerce Arc> to Arc + Send + Sync> let blob_data: Arc + Send + Sync> = frame.rgba_data.clone(); - let image = Image::new( - vello::peniko::Blob::new(blob_data), - vello::peniko::ImageFormat::Rgba8, - frame.width, - frame.height, - ); + let image_data = ImageData { + data: Blob::new(blob_data), + format: ImageFormat::Rgba8, + width: frame.width, + height: frame.height, + alpha_type: ImageAlphaType::Alpha, + }; + let image = ImageBrush::new(image_data); // Apply opacity let image_with_alpha = image.with_alpha(final_opacity); diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index a8770db..671897c 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -14,6 +14,7 @@ ffmpeg-next = { version = "8.0", features = ["static"] } eframe = { workspace = true } egui_extras = { workspace = true } egui-wgpu = { workspace = true } +egui_code_editor = { workspace = true } # GPU wgpu = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs b/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs new file mode 100644 index 0000000..7099ca5 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs @@ -0,0 +1,323 @@ +//! GPU-rendered effect thumbnails +//! +//! Generates preview thumbnails for effects by applying them to a source image +//! using the actual WGSL shaders. + +use lightningbeam_core::effect::{EffectDefinition, EffectInstance}; +use lightningbeam_core::gpu::effect_processor::{EffectProcessor, EffectUniforms}; +use std::collections::HashMap; +use uuid::Uuid; + +/// Size of effect thumbnails in pixels +pub const EFFECT_THUMBNAIL_SIZE: u32 = 64; + +/// Embedded still-life image for effect preview thumbnails +const EFFECT_PREVIEW_IMAGE_BYTES: &[u8] = include_bytes!("../../../src/assets/still-life.jpg"); + +/// Generator for GPU-rendered effect thumbnails +pub struct EffectThumbnailGenerator { + /// Effect processor for compiling and applying shaders + effect_processor: EffectProcessor, + /// Source texture (still-life image scaled to thumbnail size) + source_texture: wgpu::Texture, + /// View of the source texture + source_view: wgpu::TextureView, + /// Destination texture for rendered effects + dest_texture: wgpu::Texture, + /// View of the destination texture + dest_view: wgpu::TextureView, + /// Buffer for reading back rendered thumbnails + readback_buffer: wgpu::Buffer, + /// Cached rendered thumbnails (effect_id -> RGBA data) + thumbnail_cache: HashMap>, + /// Effects that need thumbnail generation + pending_effects: Vec, +} + +impl EffectThumbnailGenerator { + /// Create a new effect thumbnail generator + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { + // Load and decode the source image + let source_rgba = Self::load_source_image(); + + // Create effect processor (using Rgba8Unorm for thumbnail output) + let effect_processor = EffectProcessor::new(device, wgpu::TextureFormat::Rgba8Unorm); + + // Create source texture + let source_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("effect_thumbnail_source"), + size: wgpu::Extent3d { + width: EFFECT_THUMBNAIL_SIZE, + height: EFFECT_THUMBNAIL_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + // Upload source image data + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &source_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &source_rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(EFFECT_THUMBNAIL_SIZE * 4), + rows_per_image: Some(EFFECT_THUMBNAIL_SIZE), + }, + wgpu::Extent3d { + width: EFFECT_THUMBNAIL_SIZE, + height: EFFECT_THUMBNAIL_SIZE, + depth_or_array_layers: 1, + }, + ); + + let source_view = source_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // Create destination texture + let dest_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("effect_thumbnail_dest"), + size: wgpu::Extent3d { + width: EFFECT_THUMBNAIL_SIZE, + height: EFFECT_THUMBNAIL_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + let dest_view = dest_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // Create readback buffer + let buffer_size = (EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as u64; + // Align to 256 bytes for wgpu requirements + let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256; + let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("effect_thumbnail_readback"), + size: (aligned_bytes_per_row * EFFECT_THUMBNAIL_SIZE) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + Self { + effect_processor, + source_texture, + source_view, + dest_texture, + dest_view, + readback_buffer, + thumbnail_cache: HashMap::new(), + pending_effects: Vec::new(), + } + } + + /// Load and resize the source image to thumbnail size + fn load_source_image() -> Vec { + // Try to load the embedded image + if let Ok(img) = image::load_from_memory(EFFECT_PREVIEW_IMAGE_BYTES) { + // Resize to thumbnail size + let resized = img.resize_exact( + EFFECT_THUMBNAIL_SIZE, + EFFECT_THUMBNAIL_SIZE, + image::imageops::FilterType::Lanczos3, + ); + return resized.to_rgba8().into_raw(); + } + + // Fallback: generate a gradient image + let size = EFFECT_THUMBNAIL_SIZE as usize; + let mut rgba = vec![0u8; size * size * 4]; + for y in 0..size { + for x in 0..size { + let idx = (y * size + x) * 4; + // Create a colorful gradient + rgba[idx] = (x * 255 / size) as u8; // R: horizontal gradient + rgba[idx + 1] = (y * 255 / size) as u8; // G: vertical gradient + rgba[idx + 2] = 128; // B: constant + rgba[idx + 3] = 255; // A: opaque + } + } + rgba + } + + /// Request thumbnail generation for an effect + pub fn request_thumbnail(&mut self, effect_id: Uuid) { + if !self.thumbnail_cache.contains_key(&effect_id) && !self.pending_effects.contains(&effect_id) { + self.pending_effects.push(effect_id); + } + } + + /// Get a cached thumbnail, or None if not yet generated + pub fn get_thumbnail(&self, effect_id: &Uuid) -> Option<&Vec> { + self.thumbnail_cache.get(effect_id) + } + + /// Check if a thumbnail is cached + pub fn has_thumbnail(&self, effect_id: &Uuid) -> bool { + self.thumbnail_cache.contains_key(effect_id) + } + + /// Invalidate a cached thumbnail (e.g., when effect shader changes) + pub fn invalidate(&mut self, effect_id: &Uuid) { + self.thumbnail_cache.remove(effect_id); + self.effect_processor.remove_effect(effect_id); + } + + /// Generate thumbnails for pending effects (call once per frame) + /// + /// Returns the number of thumbnails generated this frame. + pub fn generate_pending( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + effect_definitions: &HashMap, + max_per_frame: usize, + ) -> usize { + let mut generated = 0; + + while generated < max_per_frame && !self.pending_effects.is_empty() { + let effect_id = self.pending_effects.remove(0); + + // Get effect definition + let Some(definition) = effect_definitions.get(&effect_id) else { + continue; + }; + + // Try to generate thumbnail + if let Some(rgba) = self.render_effect_thumbnail(device, queue, definition) { + self.thumbnail_cache.insert(effect_id, rgba); + generated += 1; + } + } + + generated + } + + /// Render a single effect thumbnail + fn render_effect_thumbnail( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + definition: &EffectDefinition, + ) -> Option> { + // Compile the effect if not already compiled + if !self.effect_processor.compile_effect(device, definition) { + eprintln!("Failed to compile effect shader: {}", definition.name); + return None; + } + + // Create a default effect instance (default parameter values) + let instance = EffectInstance::new(definition, 0.0, 1.0); + + // Create command encoder + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("effect_thumbnail_encoder"), + }); + + // Apply effect + let success = self.effect_processor.apply_effect( + device, + queue, + &mut encoder, + definition, + &instance, + &self.source_view, + &self.dest_view, + EFFECT_THUMBNAIL_SIZE, + EFFECT_THUMBNAIL_SIZE, + 0.0, // time = 0 + ); + + if !success { + eprintln!("Failed to apply effect: {}", definition.name); + return None; + } + + // Copy result to readback buffer + let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256; + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &self.dest_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &self.readback_buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(aligned_bytes_per_row), + rows_per_image: Some(EFFECT_THUMBNAIL_SIZE), + }, + }, + wgpu::Extent3d { + width: EFFECT_THUMBNAIL_SIZE, + height: EFFECT_THUMBNAIL_SIZE, + depth_or_array_layers: 1, + }, + ); + + // Submit commands + queue.submit(std::iter::once(encoder.finish())); + + // Map buffer and read data + let buffer_slice = self.readback_buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + buffer_slice.map_async(wgpu::MapMode::Read, move |result| { + tx.send(result).unwrap(); + }); + + // Wait for GPU + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + + // Check if mapping succeeded + if rx.recv().ok()?.is_err() { + eprintln!("Failed to map readback buffer"); + return None; + } + + // Copy data from mapped buffer (handling row alignment) + let data = buffer_slice.get_mapped_range(); + let mut rgba = Vec::with_capacity((EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as usize); + + for row in 0..EFFECT_THUMBNAIL_SIZE { + let row_start = (row * aligned_bytes_per_row) as usize; + let row_end = row_start + (EFFECT_THUMBNAIL_SIZE * 4) as usize; + rgba.extend_from_slice(&data[row_start..row_end]); + } + + drop(data); + self.readback_buffer.unmap(); + + Some(rgba) + } + + /// Get all effect IDs that have pending thumbnail requests + pub fn pending_count(&self) -> usize { + self.pending_effects.len() + } + + /// Get read-only access to the thumbnail cache + pub fn thumbnail_cache(&self) -> &HashMap> { + &self.thumbnail_cache + } + + /// Add multiple thumbnail requests at once + pub fn request_thumbnails(&mut self, effect_ids: &[Uuid]) { + for id in effect_ids { + self.request_thumbnail(*id); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index 6030a47..cb19df2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -621,7 +621,7 @@ pub fn render_frame_to_rgba( sender.send(result).ok(); }); - device.poll(wgpu::Maintain::Wait); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); receiver .recv() @@ -926,6 +926,7 @@ pub fn render_frame_to_rgba_hdr( load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, @@ -990,7 +991,7 @@ pub fn render_frame_to_rgba_hdr( sender.send(result).ok(); }); - device.poll(wgpu::Maintain::Wait); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); receiver .recv() diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 9085aeb..8726624 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -28,6 +28,9 @@ mod default_instrument; mod export; +mod effect_thumbnails; +use effect_thumbnails::EffectThumbnailGenerator; + /// Lightningbeam Editor - Animation and video editing software #[derive(Parser, Debug)] #[command(name = "Lightningbeam Editor")] @@ -535,6 +538,10 @@ struct EditorApp { 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 + // Shader editor inter-pane communication + effect_to_load: Option, // Effect ID to load into shader editor (set by asset library) + // Effect thumbnail invalidation queue (persists across frames until processed) + effect_thumbnails_to_invalidate: Vec, // Import dialog state last_import_filter: ImportFilter, // Last used import filter (remembered across imports) // Tool-specific options (displayed in infopanel) @@ -582,6 +589,8 @@ struct EditorApp { export_progress_dialog: export::dialog::ExportProgressDialog, /// Export orchestrator for background exports export_orchestrator: Option, + /// GPU-rendered effect thumbnail generator + effect_thumbnail_generator: Option, } /// Import filter types for the file dialog @@ -709,6 +718,8 @@ impl EditorApp { playback_time: 0.0, // Start at beginning is_playing: false, // Start paused dragging_asset: None, // No asset being dragged initially + effect_to_load: None, // No effect to load initially + effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially last_import_filter: ImportFilter::default(), // Default to "All Supported" stroke_width: 3.0, // Default stroke width fill_enabled: true, // Default to filling shapes @@ -729,6 +740,7 @@ impl EditorApp { export_dialog: export::dialog::ExportDialog::default(), export_progress_dialog: export::dialog::ExportProgressDialog::default(), export_orchestrator: None, + effect_thumbnail_generator: None, // Initialized when GPU available } } @@ -2413,6 +2425,51 @@ impl eframe::App for EditorApp { self.fetch_waveform(pool_index); } + // Initialize and update effect thumbnail generator (GPU-based effect previews) + if let Some(render_state) = frame.wgpu_render_state() { + let device = &render_state.device; + let queue = &render_state.queue; + + // Initialize on first GPU access + if self.effect_thumbnail_generator.is_none() { + self.effect_thumbnail_generator = Some(EffectThumbnailGenerator::new(device, queue)); + println!("✅ Effect thumbnail generator initialized"); + } + + // Process effect thumbnail invalidations from previous frame + // This happens BEFORE UI rendering so asset library will see empty GPU cache + // We only invalidate GPU cache here - asset library will see the list during render + // and invalidate its own ThumbnailCache + if !self.effect_thumbnails_to_invalidate.is_empty() { + if let Some(generator) = &mut self.effect_thumbnail_generator { + for effect_id in &self.effect_thumbnails_to_invalidate { + generator.invalidate(effect_id); + } + } + // DON'T clear here - asset library still needs to see these during render + } + + // Generate pending effect thumbnails (up to 2 per frame to avoid stalls) + if let Some(generator) = &mut self.effect_thumbnail_generator { + // Combine built-in effects from registry with custom effects from document + let mut all_effects: HashMap = HashMap::new(); + for def in lightningbeam_core::effect_registry::EffectRegistry::get_all() { + all_effects.insert(def.id, def); + } + for (id, def) in &self.action_executor.document().effect_definitions { + all_effects.insert(*id, def.clone()); + } + + let generated = generator.generate_pending(device, queue, &all_effects, 2); + if generated > 0 { + // Request repaint to continue generating remaining thumbnails + if generator.pending_count() > 0 { + ctx.request_repaint(); + } + } + } + } + // Handle file operation progress if let Some(ref mut operation) = self.file_operation { // Set wait cursor @@ -2810,6 +2867,11 @@ impl eframe::App for EditorApp { // Registry for actions to execute after rendering (two-phase dispatch) let mut pending_actions: Vec> = Vec::new(); + // Queue for effect thumbnail requests (collected during rendering) + let mut effect_thumbnail_requests: Vec = Vec::new(); + // Empty cache fallback if generator not initialized + let empty_thumbnail_cache: HashMap> = HashMap::new(); + // Create render context let mut ctx = RenderContext { tool_icon_cache: &mut self.tool_icon_cache, @@ -2846,6 +2908,12 @@ impl eframe::App for EditorApp { waveform_chunk_cache: &self.waveform_chunk_cache, waveform_image_cache: &mut self.waveform_image_cache, audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms, + effect_to_load: &mut self.effect_to_load, + effect_thumbnail_requests: &mut effect_thumbnail_requests, + effect_thumbnail_cache: self.effect_thumbnail_generator.as_ref() + .map(|g| g.thumbnail_cache()) + .unwrap_or(&empty_thumbnail_cache), + effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate, }; render_layout_node( @@ -2861,6 +2929,14 @@ impl eframe::App for EditorApp { &mut ctx, ); + // Process collected effect thumbnail requests + if !effect_thumbnail_requests.is_empty() { + if let Some(generator) = &mut self.effect_thumbnail_generator { + generator.request_thumbnails(&effect_thumbnail_requests); + } + } + + // 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) { @@ -3036,6 +3112,14 @@ struct RenderContext<'a> { waveform_image_cache: &'a mut waveform_image_cache::WaveformImageCache, /// Audio pool indices with new waveform data this frame (for thumbnail invalidation) audio_pools_with_new_waveforms: &'a HashSet, + /// Effect ID to load into shader editor (set by asset library, consumed by shader editor) + effect_to_load: &'a mut Option, + /// Queue for effect thumbnail requests + effect_thumbnail_requests: &'a mut Vec, + /// Cache of generated effect thumbnails + effect_thumbnail_cache: &'a HashMap>, + /// Effect IDs whose thumbnails should be invalidated + effect_thumbnails_to_invalidate: &'a mut Vec, } /// Recursively render a layout node with drag support @@ -3400,47 +3484,41 @@ fn render_pane( ); } - // 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)); - } + // Show pane type selector menu on left click using new Popup API + egui::containers::Popup::menu(&icon_response) + .show(|ui| { + ui.set_min_width(200.0); + ui.label("Select Pane Type:"); + ui.separator(); - 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.ctx()) { + ui.horizontal(|ui| { + // Show icon + let icon_texture_id = icon.id(); + let icon_size = egui::vec2(16.0, 16.0); + ui.add(egui::Image::new((icon_texture_id, icon_size))); - 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.ctx()) { - ui.horizontal(|ui| { - // Show icon - let icon_texture_id = icon.id(); - let icon_size = egui::vec2(16.0, 16.0); - ui.add(egui::Image::new((icon_texture_id, icon_size))); - - // Show label with selection + // 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(); + } + }); + } 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()); } - }); - } 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 { @@ -3512,6 +3590,10 @@ fn render_pane( waveform_chunk_cache: ctx.waveform_chunk_cache, waveform_image_cache: ctx.waveform_image_cache, audio_pools_with_new_waveforms: ctx.audio_pools_with_new_waveforms, + effect_to_load: ctx.effect_to_load, + effect_thumbnail_requests: ctx.effect_thumbnail_requests, + effect_thumbnail_cache: ctx.effect_thumbnail_cache, + effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, }; pane_instance.render_header(&mut header_ui, &mut shared); } @@ -3571,6 +3653,10 @@ fn render_pane( waveform_chunk_cache: ctx.waveform_chunk_cache, waveform_image_cache: ctx.waveform_image_cache, audio_pools_with_new_waveforms: ctx.audio_pools_with_new_waveforms, + effect_to_load: ctx.effect_to_load, + effect_thumbnail_requests: ctx.effect_thumbnail_requests, + effect_thumbnail_cache: ctx.effect_thumbnail_cache, + effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, }; // Render pane content (header was already rendered above) @@ -3788,6 +3874,7 @@ fn pane_color(pane_type: PaneType) -> egui::Color32 { 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), + PaneType::ShaderEditor => egui::Color32::from_rgb(35, 30, 55), } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index 9af24ed..696d98c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -1173,6 +1173,254 @@ impl AssetLibraryPane { } } + /// Render a section header for effect categories + fn render_section_header(ui: &mut egui::Ui, label: &str, color: egui::Color32) { + ui.add_space(4.0); + let (header_rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 20.0), + egui::Sense::hover(), + ); + ui.painter().text( + header_rect.min + egui::vec2(8.0, 2.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::proportional(11.0), + color, + ); + ui.add_space(2.0); + } + + /// Render a grid of asset items + #[allow(clippy::too_many_arguments)] + fn render_grid_items( + &mut self, + ui: &mut egui::Ui, + assets: &[&AssetEntry], + columns: usize, + item_height: f32, + content_width: f32, + shared: &mut SharedPaneState, + document: &Document, + text_color: egui::Color32, + secondary_text_color: egui::Color32, + ) { + if assets.is_empty() { + return; + } + + let rows = (assets.len() + columns - 1) / columns; + // Grid height: matches the positioning formula used below + // Items are at: GRID_SPACING + row * (item_height + GRID_SPACING) + // Last item bottom: GRID_SPACING + (rows-1) * (item_height + GRID_SPACING) + item_height + // = GRID_SPACING + rows * item_height + (rows-1) * GRID_SPACING + // = rows * (item_height + GRID_SPACING) + GRID_SPACING - GRID_SPACING (for last row) + // Simplified: GRID_SPACING + rows * (item_height + GRID_SPACING) + let grid_height = GRID_SPACING + rows as f32 * (item_height + GRID_SPACING); + + // Reserve space for this grid section + // We need to use allocate_space to properly advance the cursor by the full height, + // then calculate the rect ourselves + let cursor_before = ui.cursor().min; + let _ = ui.allocate_space(egui::vec2(content_width, grid_height)); + let grid_rect = egui::Rect::from_min_size(cursor_before, egui::vec2(content_width, grid_height)); + + for (idx, asset) in assets.iter().enumerate() { + let col = idx % columns; + let row = idx / columns; + + let item_x = grid_rect.min.x + GRID_SPACING + col as f32 * (GRID_ITEM_SIZE + GRID_SPACING); + let item_y = grid_rect.min.y + GRID_SPACING + row as f32 * (item_height + GRID_SPACING); + + let item_rect = egui::Rect::from_min_size( + egui::pos2(item_x, item_y), + egui::vec2(GRID_ITEM_SIZE, item_height), + ); + + // Use interact() instead of allocate_rect() because we've already allocated the + // entire grid space via allocate_exact_size above - allocate_rect would double-count + let response = ui.interact(item_rect, egui::Id::new(("grid_item", asset.id)), egui::Sense::click_and_drag()); + + let is_selected = self.selected_asset == Some(asset.id); + let is_being_dragged = shared.dragging_asset.as_ref().map(|d| d.clip_id == asset.id).unwrap_or(false); + + // Item background + let item_bg = if is_being_dragged { + egui::Color32::from_rgb(80, 100, 120) + } else if is_selected { + egui::Color32::from_rgb(60, 80, 100) + } else if response.hovered() { + egui::Color32::from_rgb(45, 45, 45) + } else { + egui::Color32::from_rgb(35, 35, 35) + }; + ui.painter().rect_filled(item_rect, 4.0, item_bg); + + // Thumbnail area + let thumbnail_rect = egui::Rect::from_min_size( + egui::pos2( + item_rect.min.x + (GRID_ITEM_SIZE - THUMBNAIL_SIZE as f32) / 2.0, + item_rect.min.y + 4.0, + ), + egui::vec2(THUMBNAIL_SIZE as f32, THUMBNAIL_SIZE as f32), + ); + + // Generate and display thumbnail + let asset_id = asset.id; + let asset_category = asset.category; + let ctx = ui.ctx().clone(); + + let prefetched_waveform: Option> = + if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { + if let Some(clip) = document.audio_clips.get(&asset_id) { + if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + shared.waveform_cache.get(audio_pool_index) + .map(|peaks| peaks.iter().map(|p| (p.min, p.max)).collect()) + } else { + None + } + } else { + None + } + } else { + None + }; + + let texture = self.thumbnail_cache.get_or_create(&ctx, asset_id, || { + match asset_category { + AssetCategory::Images => document.image_assets.get(&asset_id).and_then(generate_image_thumbnail), + AssetCategory::Vector => { + let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); + document.vector_clips.get(&asset_id).map(|clip| generate_vector_thumbnail(clip, bg_color)) + } + AssetCategory::Video => generate_video_thumbnail(&asset_id, &shared.video_manager) + .or_else(|| Some(generate_placeholder_thumbnail(AssetCategory::Video, 200))), + AssetCategory::Audio => { + if let Some(clip) = document.audio_clips.get(&asset_id) { + let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); + match &clip.clip_type { + AudioClipType::Sampled { .. } => { + let wave_color = egui::Color32::from_rgb(100, 200, 100); + if let Some(ref peaks) = prefetched_waveform { + Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) + } else { + Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) + } + } + AudioClipType::Midi { midi_clip_id } => { + let note_color = egui::Color32::from_rgb(100, 200, 100); + if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { + Some(generate_midi_thumbnail(events, clip.duration, bg_color, note_color)) + } else { + Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) + } + } + } + } else { + Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) + } + } + AssetCategory::Effects => { + // Use GPU-rendered effect thumbnail if available + if let Some(rgba) = shared.effect_thumbnail_cache.get(&asset_id) { + Some(rgba.clone()) + } else { + // Request GPU thumbnail generation + shared.effect_thumbnail_requests.push(asset_id); + // Return None to avoid caching placeholder - will retry next frame + None + } + } + AssetCategory::All => None, + } + }); + + // Either use cached texture or render placeholder directly for effects + // Use painter().image() instead of ui.put() to avoid affecting the cursor + if let Some(texture) = texture { + ui.painter().image( + texture.id(), + thumbnail_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } else if asset_category == AssetCategory::Effects { + // Render effect placeholder directly (not cached) until GPU thumbnail ready + let placeholder_rgba = generate_effect_thumbnail(); + let color_image = egui::ColorImage::from_rgba_unmultiplied( + [THUMBNAIL_SIZE as usize, THUMBNAIL_SIZE as usize], + &placeholder_rgba, + ); + let texture = ctx.load_texture( + format!("effect_placeholder_{}", asset_id), + color_image, + egui::TextureOptions::LINEAR, + ); + ui.painter().image( + texture.id(), + thumbnail_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + + // Category color indicator + let indicator_rect = egui::Rect::from_min_size( + egui::pos2(thumbnail_rect.min.x, thumbnail_rect.max.y - 3.0), + egui::vec2(THUMBNAIL_SIZE as f32, 3.0), + ); + ui.painter().rect_filled(indicator_rect, 0.0, asset.category.color()); + + // Asset name + let name_display = ellipsize(&asset.name, 12); + ui.painter().text( + egui::pos2(item_rect.center().x, thumbnail_rect.max.y + 8.0), + egui::Align2::CENTER_TOP, + &name_display, + egui::FontId::proportional(10.0), + text_color, + ); + + // Handle interactions + if response.clicked() { + self.selected_asset = Some(asset.id); + } + + if response.secondary_clicked() { + if let Some(pos) = ui.ctx().pointer_interact_pos() { + self.context_menu = Some(ContextMenuState { asset_id: asset.id, position: pos }); + } + } + + if response.double_clicked() { + if asset.category == AssetCategory::Effects { + *shared.effect_to_load = Some(asset.id); + } else if !asset.is_builtin { + self.rename_state = Some(RenameState { + asset_id: asset.id, + category: asset.category, + edit_text: asset.name.clone(), + }); + } + } + + if response.drag_started() { + let linked_audio_clip_id = if asset.drag_clip_type == DragClipType::Video { + document.video_clips.get(&asset.id).and_then(|video| video.linked_audio_clip_id) + } else { + None + }; + *shared.dragging_asset = Some(DraggingAsset { + clip_id: asset.id, + clip_type: asset.drag_clip_type, + name: asset.name.clone(), + duration: asset.duration, + dimensions: asset.dimensions, + linked_audio_clip_id, + }); + } + } + } + /// Render assets based on current view mode fn render_assets( &mut self, @@ -1244,7 +1492,60 @@ impl AssetLibraryPane { .show(ui, |ui| { ui.set_min_width(scroll_area_rect.width() - 16.0); // Account for scrollbar - for asset in assets { + // For Effects tab, reorder: built-in first, then custom, with headers + let ordered_assets: Vec<&AssetEntry>; + let show_effects_sections = self.selected_category == AssetCategory::Effects; + + let assets_to_render = if show_effects_sections { + let builtin: Vec<_> = assets.iter().filter(|a| a.is_builtin).copied().collect(); + let custom: Vec<_> = assets.iter().filter(|a| !a.is_builtin).copied().collect(); + ordered_assets = builtin.into_iter().chain(custom.into_iter()).collect(); + &ordered_assets[..] + } else { + assets + }; + + // Track whether we need to render section headers + let builtin_count = if show_effects_sections { + assets.iter().filter(|a| a.is_builtin).count() + } else { + 0 + }; + let custom_count = if show_effects_sections { + assets.iter().filter(|a| !a.is_builtin).count() + } else { + 0 + }; + let mut rendered_builtin_header = false; + let mut rendered_custom_header = false; + let mut builtin_rendered = 0; + + for asset in assets_to_render { + // Render section headers for Effects tab + if show_effects_sections { + if asset.is_builtin && !rendered_builtin_header && builtin_count > 0 { + Self::render_section_header(ui, "Built-in Effects", secondary_text_color); + rendered_builtin_header = true; + } + if !asset.is_builtin && !rendered_custom_header && custom_count > 0 { + // Add separator before custom section if there were built-in effects + if builtin_count > 0 { + ui.add_space(8.0); + let separator_rect = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 1.0), + egui::Sense::hover(), + ).0; + ui.painter().rect_filled(separator_rect, 0.0, egui::Color32::from_gray(60)); + ui.add_space(8.0); + } + Self::render_section_header(ui, "Custom Effects", secondary_text_color); + rendered_custom_header = true; + } + if asset.is_builtin { + builtin_rendered += 1; + } + } + let (item_rect, response) = ui.allocate_exact_size( egui::vec2(ui.available_width(), ITEM_HEIGHT), egui::Sense::click_and_drag(), @@ -1413,16 +1714,40 @@ impl AssetLibraryPane { } } AssetCategory::Effects => { - Some(generate_effect_thumbnail()) + // Use GPU-rendered effect thumbnail if available + if let Some(rgba) = shared.effect_thumbnail_cache.get(&asset.id) { + Some(rgba.clone()) + } else { + // Request GPU thumbnail generation + shared.effect_thumbnail_requests.push(asset.id); + // Return None to avoid caching placeholder - will retry next frame + None + } } AssetCategory::All => None, } }); + // Either use cached texture or render placeholder directly for effects if let Some(texture) = texture { let image = egui::Image::new(texture) .fit_to_exact_size(egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE)); ui.put(thumbnail_rect, image); + } else if asset.category == AssetCategory::Effects { + // Render effect placeholder directly (not cached) until GPU thumbnail ready + let placeholder_rgba = generate_effect_thumbnail(); + let color_image = egui::ColorImage::from_rgba_unmultiplied( + [THUMBNAIL_SIZE as usize, THUMBNAIL_SIZE as usize], + &placeholder_rgba, + ); + let texture = ctx.load_texture( + format!("effect_placeholder_{}", asset.id), + color_image, + egui::TextureOptions::LINEAR, + ); + let image = egui::Image::new(&texture) + .fit_to_exact_size(egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE)); + ui.put(thumbnail_rect, image); } // Handle click (selection) @@ -1440,13 +1765,19 @@ impl AssetLibraryPane { } } - // Handle double-click (start rename) + // Handle double-click if response.double_clicked() { - self.rename_state = Some(RenameState { - asset_id: asset.id, - category: asset.category, - edit_text: asset.name.clone(), - }); + // For effects, open in shader editor + if asset.category == AssetCategory::Effects { + *shared.effect_to_load = Some(asset.id); + } else if !asset.is_builtin { + // For other non-builtin assets, start rename + self.rename_state = Some(RenameState { + asset_id: asset.id, + category: asset.category, + edit_text: asset.name.clone(), + }); + } } // Handle drag start @@ -1568,219 +1899,74 @@ impl AssetLibraryPane { .floor() .max(1.0) as usize; let item_height = GRID_ITEM_SIZE + 20.0; // 20 for name below thumbnail - let rows = (assets.len() + columns - 1) / columns; - let total_height = GRID_SPACING + rows as f32 * (item_height + GRID_SPACING); - // Use egui's built-in ScrollArea for scrolling + // For Effects tab, reorder: built-in first, then custom + let ordered_assets: Vec<&AssetEntry>; + let show_effects_sections = self.selected_category == AssetCategory::Effects; + + let assets_to_render: &[&AssetEntry] = if show_effects_sections { + let builtin: Vec<_> = assets.iter().filter(|a| a.is_builtin).copied().collect(); + let custom: Vec<_> = assets.iter().filter(|a| !a.is_builtin).copied().collect(); + ordered_assets = builtin.into_iter().chain(custom.into_iter()).collect(); + &ordered_assets[..] + } else { + assets + }; + + let builtin_count = if show_effects_sections { + assets.iter().filter(|a| a.is_builtin).count() + } else { + 0 + }; + let custom_count = if show_effects_sections { + assets.iter().filter(|a| !a.is_builtin).count() + } else { + 0 + }; + ui.allocate_new_ui(egui::UiBuilder::new().max_rect(rect), |ui| { egui::ScrollArea::vertical() .id_salt(("asset_grid_scroll", path)) .auto_shrink([false, false]) .show(ui, |ui| { - // Reserve space for the entire grid - let (grid_rect, _) = ui.allocate_exact_size( - egui::vec2(content_width, total_height), - egui::Sense::hover(), - ); + ui.set_min_width(content_width); - for (idx, asset) in assets.iter().enumerate() { - let col = idx % columns; - let row = idx / columns; + // Render built-in section header + if show_effects_sections && builtin_count > 0 { + Self::render_section_header(ui, "Built-in Effects", secondary_text_color); + } - // Calculate item position with proper spacing - let item_x = grid_rect.min.x + GRID_SPACING + col as f32 * (GRID_ITEM_SIZE + GRID_SPACING); - let item_y = grid_rect.min.y + GRID_SPACING + row as f32 * (item_height + GRID_SPACING); + // First pass: render built-in items + let builtin_items: Vec<_> = assets_to_render.iter().filter(|a| a.is_builtin).copied().collect(); + if !builtin_items.is_empty() { + self.render_grid_items(ui, &builtin_items, columns, item_height, content_width, shared, document, text_color, secondary_text_color); + } - let item_rect = egui::Rect::from_min_size( - egui::pos2(item_x, item_y), - egui::vec2(GRID_ITEM_SIZE, item_height), - ); + // Separator between sections + if show_effects_sections && builtin_count > 0 && custom_count > 0 { + ui.add_space(8.0); + let separator_rect = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 1.0), + egui::Sense::hover(), + ).0; + ui.painter().rect_filled(separator_rect, 0.0, egui::Color32::from_gray(60)); + ui.add_space(8.0); + } - // Allocate the response for this item - let response = ui.allocate_rect(item_rect, egui::Sense::click_and_drag()); + // Render custom section header + if show_effects_sections && custom_count > 0 { + Self::render_section_header(ui, "Custom Effects", secondary_text_color); + } - let is_selected = self.selected_asset == Some(asset.id); - let is_being_dragged = shared - .dragging_asset - .as_ref() - .map(|d| d.clip_id == asset.id) - .unwrap_or(false); + // Second pass: render custom items + let custom_items: Vec<_> = assets_to_render.iter().filter(|a| !a.is_builtin).copied().collect(); + if !custom_items.is_empty() { + self.render_grid_items(ui, &custom_items, columns, item_height, content_width, shared, document, text_color, secondary_text_color); + } - // Item background - let item_bg = if is_being_dragged { - egui::Color32::from_rgb(80, 100, 120) - } else if is_selected { - egui::Color32::from_rgb(60, 80, 100) - } else if response.hovered() { - egui::Color32::from_rgb(45, 45, 45) - } else { - egui::Color32::from_rgb(35, 35, 35) - }; - ui.painter().rect_filled(item_rect, 4.0, item_bg); - - // Thumbnail area (64x64 centered in 80px width) - let thumbnail_rect = egui::Rect::from_min_size( - egui::pos2( - item_rect.min.x + (GRID_ITEM_SIZE - THUMBNAIL_SIZE as f32) / 2.0, - item_rect.min.y + 4.0, - ), - egui::vec2(THUMBNAIL_SIZE as f32, THUMBNAIL_SIZE as f32), - ); - - // Generate and display thumbnail based on asset type - let asset_id = asset.id; - let asset_category = asset.category; - let ctx = ui.ctx().clone(); - - // Get waveform data from cache if thumbnail not already cached - let prefetched_waveform: Option> = - if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { - if let Some(clip) = document.audio_clips.get(&asset_id) { - if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { - // Use cached waveform data (populated by fetch_waveform in main.rs) - let waveform = shared.waveform_cache.get(audio_pool_index) - .map(|peaks| peaks.iter().map(|p| (p.min, p.max)).collect()); - if waveform.is_some() { - println!("🎵 Found waveform for pool {} (asset {})", audio_pool_index, asset_id); - } else { - println!("⚠️ No waveform yet for pool {} (asset {})", audio_pool_index, asset_id); - } - waveform - } else { - None - } - } else { - None - } - } else { - None - }; - - let texture = self.thumbnail_cache.get_or_create(&ctx, asset_id, || { - match asset_category { - AssetCategory::Images => { - document.image_assets.get(&asset_id) - .and_then(generate_image_thumbnail) - } - AssetCategory::Vector => { - // Render frame 0 of vector clip using tiny-skia - let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - document.vector_clips.get(&asset_id) - .map(|clip| generate_vector_thumbnail(clip, bg_color)) - } - AssetCategory::Video => { - // Generate video thumbnail from first frame - generate_video_thumbnail(&asset_id, &shared.video_manager) - .or_else(|| Some(generate_placeholder_thumbnail(AssetCategory::Video, 200))) - } - AssetCategory::Audio => { - if let Some(clip) = document.audio_clips.get(&asset_id) { - let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { .. } => { - let wave_color = egui::Color32::from_rgb(100, 200, 100); - if let Some(ref peaks) = prefetched_waveform { - println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id); - Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) - } else { - println!("📦 Generating placeholder thumbnail for asset {}", asset_id); - Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) - } - } - AudioClipType::Midi { midi_clip_id } => { - let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - let note_color = egui::Color32::from_rgb(100, 200, 100); - - if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { - Some(generate_midi_thumbnail(events, clip.duration, bg_color, note_color)) - } else { - Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) - } - } - } - } else { - Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) - } - } - AssetCategory::Effects => { - Some(generate_effect_thumbnail()) - } - AssetCategory::All => None, - } - }); - - if let Some(texture) = texture { - let image = egui::Image::new(texture) - .fit_to_exact_size(egui::vec2(THUMBNAIL_SIZE as f32, THUMBNAIL_SIZE as f32)); - ui.put(thumbnail_rect, image); - } - - // Category color indicator (small bar at bottom of thumbnail) - let indicator_rect = egui::Rect::from_min_size( - egui::pos2(thumbnail_rect.min.x, thumbnail_rect.max.y - 3.0), - egui::vec2(THUMBNAIL_SIZE as f32, 3.0), - ); - ui.painter().rect_filled(indicator_rect, 0.0, asset.category.color()); - - // Asset name below thumbnail (ellipsized) - let name_display = ellipsize(&asset.name, 12); - let name_pos = egui::pos2( - item_rect.center().x, - thumbnail_rect.max.y + 8.0, - ); - ui.painter().text( - name_pos, - egui::Align2::CENTER_TOP, - &name_display, - egui::FontId::proportional(10.0), - text_color, - ); - - // Handle click (selection) - if response.clicked() { - self.selected_asset = Some(asset.id); - } - - // Handle right-click (context menu) - if response.secondary_clicked() { - if let Some(pos) = ui.ctx().pointer_interact_pos() { - self.context_menu = Some(ContextMenuState { - asset_id: asset.id, - position: pos, - }); - } - } - - // Handle double-click (start rename) - if response.double_clicked() { - self.rename_state = Some(RenameState { - asset_id: asset.id, - category: asset.category, - edit_text: asset.name.clone(), - }); - } - - // Handle drag start - if response.drag_started() { - // For video clips, get the linked audio clip ID - let linked_audio_clip_id = if asset.drag_clip_type == DragClipType::Video { - let result = document.video_clips.get(&asset.id) - .and_then(|video| video.linked_audio_clip_id); - eprintln!("DEBUG DRAG: Video clip {} has linked audio: {:?}", asset.id, result); - result - } else { - None - }; - - *shared.dragging_asset = Some(DraggingAsset { - clip_id: asset.id, - clip_type: asset.drag_clip_type, - name: asset.name.clone(), - duration: asset.duration, - dimensions: asset.dimensions, - linked_audio_clip_id, - }); - } + // For non-Effects tabs, just render all items + if !show_effects_sections { + self.render_grid_items(ui, assets_to_render, columns, item_height, content_width, shared, document, text_color, secondary_text_color); } }); }); @@ -1857,6 +2043,16 @@ impl PaneRenderer for AssetLibraryPane { } } + // Invalidate thumbnails for effects that were edited (shader code changed) + if !shared.effect_thumbnails_to_invalidate.is_empty() { + for effect_id in shared.effect_thumbnails_to_invalidate.iter() { + self.thumbnail_cache.invalidate(effect_id); + } + // Clear after processing - we've handled these + shared.effect_thumbnails_to_invalidate.clear(); + ui.ctx().request_repaint(); + } + // Collect and filter assets let all_assets = self.collect_assets(&document_arc); let filtered_assets = self.filter_assets(&all_assets); @@ -1905,6 +2101,15 @@ impl PaneRenderer for AssetLibraryPane { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(120.0); + // Add "Edit in Shader Editor" for effects + if asset_category == AssetCategory::Effects { + if ui.button("Edit in Shader Editor").clicked() { + *shared.effect_to_load = Some(context_asset_id); + self.context_menu = None; + } + ui.separator(); + } + // Built-in effects cannot be renamed or deleted if asset_is_builtin { ui.label(egui::RichText::new("Built-in effect") diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 5a4256b..33006a7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -64,6 +64,7 @@ pub mod virtual_piano; pub mod node_editor; pub mod preset_browser; pub mod asset_library; +pub mod shader_editor; /// Which color mode is active for the eyedropper tool #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -191,6 +192,14 @@ pub struct SharedPaneState<'a> { pub waveform_image_cache: &'a mut crate::waveform_image_cache::WaveformImageCache, /// Audio pool indices that got new waveform data this frame (for thumbnail invalidation) pub audio_pools_with_new_waveforms: &'a std::collections::HashSet, + /// Effect ID to load into shader editor (set by asset library, consumed by shader editor) + pub effect_to_load: &'a mut Option, + /// Queue for effect thumbnail requests (effect IDs to generate thumbnails for) + pub effect_thumbnail_requests: &'a mut Vec, + /// Cache of generated effect thumbnails (effect_id -> RGBA data) + pub effect_thumbnail_cache: &'a std::collections::HashMap>, + /// Effect IDs whose thumbnails should be invalidated (e.g., after shader edit) + pub effect_thumbnails_to_invalidate: &'a mut Vec, } /// Trait for pane rendering @@ -231,6 +240,7 @@ pub enum PaneInstance { NodeEditor(node_editor::NodeEditorPane), PresetBrowser(preset_browser::PresetBrowserPane), AssetLibrary(asset_library::AssetLibraryPane), + ShaderEditor(shader_editor::ShaderEditorPane), } impl PaneInstance { @@ -251,6 +261,9 @@ impl PaneInstance { PaneType::AssetLibrary => { PaneInstance::AssetLibrary(asset_library::AssetLibraryPane::new()) } + PaneType::ShaderEditor => { + PaneInstance::ShaderEditor(shader_editor::ShaderEditorPane::new()) + } } } @@ -267,6 +280,7 @@ impl PaneInstance { PaneInstance::NodeEditor(_) => PaneType::NodeEditor, PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser, PaneInstance::AssetLibrary(_) => PaneType::AssetLibrary, + PaneInstance::ShaderEditor(_) => PaneType::ShaderEditor, } } } @@ -284,6 +298,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::NodeEditor(p) => p.render_header(ui, shared), PaneInstance::PresetBrowser(p) => p.render_header(ui, shared), PaneInstance::AssetLibrary(p) => p.render_header(ui, shared), + PaneInstance::ShaderEditor(p) => p.render_header(ui, shared), } } @@ -305,6 +320,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::NodeEditor(p) => p.render_content(ui, rect, path, shared), PaneInstance::PresetBrowser(p) => p.render_content(ui, rect, path, shared), PaneInstance::AssetLibrary(p) => p.render_content(ui, rect, path, shared), + PaneInstance::ShaderEditor(p) => p.render_content(ui, rect, path, shared), } } @@ -320,6 +336,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::NodeEditor(p) => p.name(), PaneInstance::PresetBrowser(p) => p.name(), PaneInstance::AssetLibrary(p) => p.name(), + PaneInstance::ShaderEditor(p) => p.name(), } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs new file mode 100644 index 0000000..4ca29f3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs @@ -0,0 +1,581 @@ +/// Shader Editor pane - WGSL shader code editor with syntax highlighting +/// +/// Provides a code editor for creating and editing custom effect shaders. +/// Features: +/// - Syntax highlighting for WGSL +/// - Line numbers +/// - Basic validation feedback +/// - Template shader insertion + +use eframe::egui::{self, Ui}; +use egui_code_editor::{CodeEditor, ColorTheme, Syntax}; +use lightningbeam_core::effect::{EffectCategory, EffectDefinition}; +use lightningbeam_core::effect_registry::EffectRegistry; +use uuid::Uuid; +use super::{NodePath, PaneRenderer, SharedPaneState}; + +/// Result from the unsaved changes dialog +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnsavedDialogResult { + Cancel, + Discard, + SaveAndContinue, +} + +/// Custom syntax definition for WGSL (WebGPU Shading Language) +fn wgsl_syntax() -> Syntax { + Syntax { + language: "WGSL", + case_sensitive: true, + comment: "//", + comment_multiline: ["/*", "*/"], + hyperlinks: std::collections::BTreeSet::new(), + keywords: std::collections::BTreeSet::from([ + // Control flow + "if", "else", "for", "while", "loop", "break", "continue", "return", + "switch", "case", "default", "discard", + // Declarations + "fn", "let", "var", "const", "struct", "alias", "type", + // Storage classes and access modes + "function", "private", "workgroup", "uniform", "storage", + "read", "write", "read_write", + // Shader stages + "vertex", "fragment", "compute", + // Attributes + "location", "builtin", "group", "binding", + // Built-in values + "position", "vertex_index", "instance_index", "front_facing", + "frag_depth", "local_invocation_id", "local_invocation_index", + "global_invocation_id", "workgroup_id", "num_workgroups", + "sample_index", "sample_mask", + ]), + types: std::collections::BTreeSet::from([ + // Scalar types + "bool", "i32", "u32", "f32", "f16", + // Vector types + "vec2", "vec3", "vec4", + "vec2i", "vec3i", "vec4i", + "vec2u", "vec3u", "vec4u", + "vec2f", "vec3f", "vec4f", + "vec2h", "vec3h", "vec4h", + // Matrix types + "mat2x2", "mat2x3", "mat2x4", + "mat3x2", "mat3x3", "mat3x4", + "mat4x2", "mat4x3", "mat4x4", + "mat2x2f", "mat3x3f", "mat4x4f", + // Texture types + "texture_1d", "texture_2d", "texture_2d_array", "texture_3d", + "texture_cube", "texture_cube_array", "texture_multisampled_2d", + "texture_storage_1d", "texture_storage_2d", "texture_storage_2d_array", + "texture_storage_3d", "texture_depth_2d", "texture_depth_2d_array", + "texture_depth_cube", "texture_depth_cube_array", "texture_depth_multisampled_2d", + // Sampler types + "sampler", "sampler_comparison", + // Array and pointer + "array", "ptr", + ]), + special: std::collections::BTreeSet::from([ + // Built-in functions (subset) + "abs", "acos", "all", "any", "asin", "atan", "atan2", + "ceil", "clamp", "cos", "cosh", "cross", + "degrees", "determinant", "distance", "dot", + "exp", "exp2", "faceForward", "floor", "fma", "fract", + "length", "log", "log2", + "max", "min", "mix", "modf", "normalize", + "pow", "radians", "reflect", "refract", "round", + "saturate", "sign", "sin", "sinh", "smoothstep", "sqrt", "step", + "tan", "tanh", "transpose", "trunc", + // Texture functions + "textureSample", "textureSampleLevel", "textureSampleBias", + "textureSampleGrad", "textureSampleCompare", "textureLoad", + "textureStore", "textureDimensions", "textureNumLayers", + "textureNumLevels", "textureNumSamples", + // Atomic functions + "atomicLoad", "atomicStore", "atomicAdd", "atomicSub", + "atomicMax", "atomicMin", "atomicAnd", "atomicOr", "atomicXor", + "atomicExchange", "atomicCompareExchangeWeak", + // Data packing + "pack4x8snorm", "pack4x8unorm", "pack2x16snorm", "pack2x16unorm", + "unpack4x8snorm", "unpack4x8unorm", "unpack2x16snorm", "unpack2x16unorm", + // Synchronization + "storageBarrier", "workgroupBarrier", "workgroupUniformLoad", + // Type constructors + "select", "bitcast", + ]), + } +} + +/// Default WGSL shader template for custom effects +const DEFAULT_SHADER_TEMPLATE: &str = r#"// Custom Effect Shader +// Input: source_tex (the layer content) +// Output: vec4 color at each pixel + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +// Fullscreen triangle strip +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + // Sample the source texture + let color = textureSample(source_tex, source_sampler, in.uv); + + // Your effect code here - modify 'color' as desired + // Example: Return the color unchanged (passthrough) + return color; +} +"#; + +/// Grayscale effect shader template +const GRAYSCALE_TEMPLATE: &str = r#"// Grayscale Effect +// Converts the image to grayscale using luminance weights + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let color = textureSample(source_tex, source_sampler, in.uv); + + // ITU-R BT.709 luminance coefficients + let luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); + + return vec4(luminance, luminance, luminance, color.a); +} +"#; + +/// Vignette effect shader template +const VIGNETTE_TEMPLATE: &str = r#"// Vignette Effect +// Darkens the edges of the image + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let color = textureSample(source_tex, source_sampler, in.uv); + + // Calculate distance from center (0.5, 0.5) + let center = vec2(0.5, 0.5); + let dist = distance(in.uv, center); + + // Vignette parameters + let radius = 0.7; // Inner radius (no darkening) + let softness = 0.4; // Transition softness + + // Calculate vignette factor + let vignette = smoothstep(radius + softness, radius, dist); + + return vec4(color.rgb * vignette, color.a); +} +"#; + +/// Shader Editor pane state +pub struct ShaderEditorPane { + /// The shader source code being edited + shader_code: String, + /// Whether to show the template selector + show_templates: bool, + /// Error message from last compilation attempt (if any) + compile_error: Option, + /// Name for the shader/effect + shader_name: String, + /// ID of effect being edited (None = new effect) + editing_effect_id: Option, + /// Original code when effect was loaded (for dirty checking) + original_code: Option, + /// Original name when effect was loaded (for dirty checking) + original_name: Option, + /// Effect awaiting confirmation to load (when there are unsaved changes) + pending_load_effect: Option, + /// Whether to show the unsaved changes confirmation dialog + show_unsaved_dialog: bool, +} + +impl ShaderEditorPane { + pub fn new() -> Self { + Self { + shader_code: DEFAULT_SHADER_TEMPLATE.to_string(), + show_templates: false, + compile_error: None, + shader_name: "Custom Effect".to_string(), + editing_effect_id: None, + original_code: None, + original_name: None, + pending_load_effect: None, + show_unsaved_dialog: false, + } + } + + /// Check if there are unsaved changes + pub fn has_unsaved_changes(&self) -> bool { + match (&self.original_code, &self.original_name) { + (Some(orig_code), Some(orig_name)) => { + self.shader_code != *orig_code || self.shader_name != *orig_name + } + // If no original, check if we've modified from default + (None, None) => { + self.shader_code != DEFAULT_SHADER_TEMPLATE || self.shader_name != "Custom Effect" + } + _ => true, // Inconsistent state, assume dirty + } + } + + /// Load an effect into the editor + pub fn load_effect(&mut self, effect: &EffectDefinition) { + self.shader_name = effect.name.clone(); + self.shader_code = effect.shader_code.clone(); + // For built-in effects, don't set editing_effect_id (editing creates a copy) + if effect.category == EffectCategory::Custom { + self.editing_effect_id = Some(effect.id); + } else { + self.editing_effect_id = None; + } + self.original_code = Some(effect.shader_code.clone()); + self.original_name = Some(effect.name.clone()); + self.compile_error = None; + } + + /// Reset to a new blank effect + pub fn new_effect(&mut self) { + self.shader_name = "Custom Effect".to_string(); + self.shader_code = DEFAULT_SHADER_TEMPLATE.to_string(); + self.editing_effect_id = None; + self.original_code = None; + self.original_name = None; + self.compile_error = None; + } + + /// Mark the current state as saved + pub fn mark_saved(&mut self, effect_id: Uuid) { + self.editing_effect_id = Some(effect_id); + self.original_code = Some(self.shader_code.clone()); + self.original_name = Some(self.shader_name.clone()); + } + + /// Look up an effect by ID (checks document first, then built-in registry) + fn lookup_effect( + &self, + effect_id: Uuid, + document: &lightningbeam_core::document::Document, + ) -> Option { + // First check custom effects in document + if let Some(def) = document.effect_definitions.get(&effect_id) { + return Some(def.clone()); + } + // Then check built-in effects + EffectRegistry::get_by_id(&effect_id) + } + + /// Render the unsaved changes confirmation dialog + fn render_unsaved_dialog(&mut self, ui: &mut egui::Ui) -> Option { + let mut result = None; + + if self.show_unsaved_dialog { + let window_id = egui::Id::new("shader_unsaved_dialog"); + + egui::Window::new("Unsaved Changes") + .id(window_id) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ui.ctx(), |ui| { + ui.set_min_width(300.0); + + ui.label("You have unsaved changes to this shader."); + ui.label("What would you like to do?"); + ui.add_space(12.0); + + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + result = Some(UnsavedDialogResult::Cancel); + } + if ui.button("Discard Changes").clicked() { + result = Some(UnsavedDialogResult::Discard); + } + if ui.button("Save & Continue").clicked() { + result = Some(UnsavedDialogResult::SaveAndContinue); + } + }); + }); + } + + result + } + + /// Render the toolbar with template selection and actions + /// Returns true if Save was clicked + fn render_toolbar(&mut self, ui: &mut Ui, _path: &NodePath) -> bool { + let mut save_clicked = false; + ui.horizontal(|ui| { + // New button + if ui.button("New").clicked() { + // TODO: Check for unsaved changes first + self.new_effect(); + } + + ui.separator(); + + // Shader name input + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(&mut self.shader_name).desired_width(150.0)); + + ui.separator(); + + // Template dropdown + egui::ComboBox::from_label("Template") + .selected_text("Insert Template") + .show_ui(ui, |ui| { + if ui.selectable_label(false, "Basic (Passthrough)").clicked() { + self.shader_code = DEFAULT_SHADER_TEMPLATE.to_string(); + } + if ui.selectable_label(false, "Grayscale").clicked() { + self.shader_code = GRAYSCALE_TEMPLATE.to_string(); + } + if ui.selectable_label(false, "Vignette").clicked() { + self.shader_code = VIGNETTE_TEMPLATE.to_string(); + } + }); + + ui.separator(); + + // Compile button (placeholder for now) + if ui.button("Validate").clicked() { + // TODO: Integrate with wgpu shader validation + // For now, just clear any previous error + self.compile_error = None; + } + + // Save button + if ui.button("Save").clicked() { + save_clicked = true; + } + + // Show dirty indicator + if self.has_unsaved_changes() { + ui.label(egui::RichText::new("*").color(egui::Color32::YELLOW)); + } + + // Show editing mode + if let Some(_) = self.editing_effect_id { + ui.label(egui::RichText::new("(Editing)").weak()); + } else { + ui.label(egui::RichText::new("(New)").weak()); + } + }); + save_clicked + } + + /// Render the error panel if there's a compile error + fn render_error_panel(&self, ui: &mut Ui) { + if let Some(error) = &self.compile_error { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Error:").color(egui::Color32::RED)); + ui.label(error); + }); + ui.separator(); + } + } +} + +impl PaneRenderer for ShaderEditorPane { + fn render_content( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + path: &NodePath, + shared: &mut SharedPaneState, + ) { + // Handle effect loading request from asset library + if let Some(effect_id) = shared.effect_to_load.take() { + // Look up the effect + if let Some(effect) = self.lookup_effect(effect_id, shared.action_executor.document()) { + if self.has_unsaved_changes() { + // Store effect to load and show dialog + self.pending_load_effect = Some(effect); + self.show_unsaved_dialog = true; + } else { + // No unsaved changes, load immediately + self.load_effect(&effect); + } + } + } + + // Handle unsaved changes dialog + if let Some(result) = self.render_unsaved_dialog(ui) { + match result { + UnsavedDialogResult::Cancel => { + // Cancel the load, keep current state + self.pending_load_effect = None; + self.show_unsaved_dialog = false; + } + UnsavedDialogResult::Discard => { + // Discard changes and load the new effect + if let Some(effect) = self.pending_load_effect.take() { + self.load_effect(&effect); + } + self.show_unsaved_dialog = false; + } + UnsavedDialogResult::SaveAndContinue => { + // Save current work first + if !self.shader_name.trim().is_empty() { + let effect = if let Some(existing_id) = self.editing_effect_id { + EffectDefinition::with_id( + existing_id, + self.shader_name.clone(), + EffectCategory::Custom, + self.shader_code.clone(), + vec![], + ) + } else { + EffectDefinition::new( + self.shader_name.clone(), + EffectCategory::Custom, + self.shader_code.clone(), + vec![], + ) + }; + let effect_id = effect.id; + shared.action_executor.document_mut().add_effect_definition(effect); + self.mark_saved(effect_id); + // Invalidate thumbnail so it regenerates with new shader + shared.effect_thumbnails_to_invalidate.push(effect_id); + } + // Then load the new effect + if let Some(effect) = self.pending_load_effect.take() { + self.load_effect(&effect); + } + self.show_unsaved_dialog = false; + } + } + } + + // Background + ui.painter().rect_filled( + rect, + 0.0, + egui::Color32::from_rgb(25, 25, 30), + ); + + // Create content area + let content_rect = rect.shrink(8.0); + let mut content_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(content_rect) + .layout(egui::Layout::top_down(egui::Align::LEFT)), + ); + + content_ui.set_min_width(content_rect.width() - 16.0); + + // Toolbar + let save_clicked = self.render_toolbar(&mut content_ui, path); + content_ui.add_space(4.0); + content_ui.separator(); + content_ui.add_space(4.0); + + // Handle save action + if save_clicked { + if self.shader_name.trim().is_empty() { + self.compile_error = Some("Name cannot be empty".to_string()); + } else { + // Create or update EffectDefinition + let effect = if let Some(existing_id) = self.editing_effect_id { + // Update existing custom effect + EffectDefinition::with_id( + existing_id, + self.shader_name.clone(), + EffectCategory::Custom, + self.shader_code.clone(), + vec![], // No parameters for now + ) + } else { + // Create new custom effect + EffectDefinition::new( + self.shader_name.clone(), + EffectCategory::Custom, + self.shader_code.clone(), + vec![], // No parameters for now + ) + }; + + let effect_id = effect.id; + shared.action_executor.document_mut().add_effect_definition(effect); + self.mark_saved(effect_id); + // Invalidate thumbnail so it regenerates with new shader + shared.effect_thumbnails_to_invalidate.push(effect_id); + self.compile_error = None; + } + } + + // Error panel + self.render_error_panel(&mut content_ui); + + // Calculate remaining height for the code editor + let remaining_rect = content_ui.available_rect_before_wrap(); + + // Code editor + egui::ScrollArea::both() + .id_salt(("shader_editor_scroll", path)) + .auto_shrink([false, false]) + .show(&mut content_ui, |ui| { + ui.set_min_size(remaining_rect.size()); + + CodeEditor::default() + .id_source("shader_code_editor") + .with_rows(50) + .with_fontsize(13.0) + .with_theme(ColorTheme::GRUVBOX_DARK) + .with_syntax(wgsl_syntax()) + .with_numlines(true) + .show(ui, &mut self.shader_code); + }); + } + + fn name(&self) -> &str { + "Shader Editor" + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index c69e81b..910cb4f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1740,6 +1740,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, timestamp_writes: None, @@ -1828,7 +1829,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { }); // Poll the device to complete the mapping - device.poll(wgpu::Maintain::Wait); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); // Read the pixel data if receiver.recv().is_ok() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index 1edbd5e..8888656 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -151,19 +151,15 @@ impl PaneRenderer for ToolbarPane { // Draw fill color button with checkerboard for alpha draw_color_button(ui, fill_button_rect, *shared.fill_color); - if fill_response.clicked() { - // Open color picker popup - ui.memory_mut(|mem| mem.toggle_popup(fill_button_id)); - } - - // Show fill color picker popup - egui::popup::popup_below_widget(ui, fill_button_id, &fill_response, egui::popup::PopupCloseBehavior::CloseOnClickOutside, |ui: &mut egui::Ui| { - let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend); - // Track that the user interacted with the fill color - if changed { - *shared.active_color_mode = super::ColorMode::Fill; - } - }); + // Show fill color picker popup using new Popup API + egui::containers::Popup::from_toggle_button_response(&fill_response) + .show(|ui| { + let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend); + // Track that the user interacted with the fill color + if changed { + *shared.active_color_mode = super::ColorMode::Fill; + } + }); y += color_button_size + button_spacing; @@ -187,19 +183,15 @@ impl PaneRenderer for ToolbarPane { // Draw stroke color button with checkerboard for alpha draw_color_button(ui, stroke_button_rect, *shared.stroke_color); - if stroke_response.clicked() { - // Open color picker popup - ui.memory_mut(|mem| mem.toggle_popup(stroke_button_id)); - } - - // Show stroke color picker popup - egui::popup::popup_below_widget(ui, stroke_button_id, &stroke_response, egui::popup::PopupCloseBehavior::CloseOnClickOutside, |ui: &mut egui::Ui| { - let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); - // Track that the user interacted with the stroke color - if changed { - *shared.active_color_mode = super::ColorMode::Stroke; - } - }); + // Show stroke color picker popup using new Popup API + egui::containers::Popup::from_toggle_button_response(&stroke_response) + .show(|ui| { + let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); + // Track that the user interacted with the stroke color + if changed { + *shared.active_color_mode = super::ColorMode::Stroke; + } + }); } fn name(&self) -> &str { diff --git a/src/assets/still-life.jpg b/src/assets/still-life.jpg new file mode 100644 index 0000000..431165e Binary files /dev/null and b/src/assets/still-life.jpg differ