//! Asset Library pane - browse and manage project assets //! //! Displays all clips in the document organized by category: //! - Vector Clips (animations) //! - Video Clips (imported video files) //! - Audio Clips (sampled audio and MIDI) //! - Image Assets (static images) use eframe::egui; use lightningbeam_core::clip::{AudioClipType, VectorClip}; use lightningbeam_core::document::Document; use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::shape::ShapeColor; use std::collections::{HashMap, HashSet}; use uuid::Uuid; use super::{DragClipType, DraggingAsset, NodePath, PaneRenderer, SharedPaneState}; use crate::widgets::ImeTextField; /// Derive min/max peak pairs from raw audio samples for thumbnail rendering. /// Downsamples to `num_peaks` (min, max) pairs by scanning chunks of samples. fn peaks_from_raw_audio( raw: &(std::sync::Arc>, u32, u32), // (samples, sample_rate, channels) num_peaks: usize, ) -> Vec<(f32, f32)> { let (samples, _sr, channels) = raw; let ch = (*channels as usize).max(1); let total_frames = samples.len() / ch; if total_frames == 0 || num_peaks == 0 { return vec![]; } let frames_per_peak = (total_frames as f64 / num_peaks as f64).max(1.0); let mut peaks = Vec::with_capacity(num_peaks); for i in 0..num_peaks { let start = (i as f64 * frames_per_peak) as usize; let end = (((i + 1) as f64 * frames_per_peak) as usize).min(total_frames); let mut min_val = f32::MAX; let mut max_val = f32::MIN; for frame in start..end { // Mix all channels together for the thumbnail let mut sample = 0.0f32; for c in 0..ch { sample += samples[frame * ch + c]; } sample /= ch as f32; min_val = min_val.min(sample); max_val = max_val.max(sample); } if min_val <= max_val { peaks.push((min_val, max_val)); } } peaks } // Thumbnail constants const THUMBNAIL_SIZE: u32 = 64; const THUMBNAIL_PREVIEW_SECONDS: f64 = 10.0; // Layout constants const SEARCH_BAR_HEIGHT: f32 = 30.0; const CATEGORY_TAB_HEIGHT: f32 = 28.0; const BREADCRUMB_HEIGHT: f32 = 24.0; const ITEM_HEIGHT: f32 = 40.0; #[allow(dead_code)] const ITEM_PADDING: f32 = 4.0; const LIST_THUMBNAIL_SIZE: f32 = 32.0; const GRID_ITEM_SIZE: f32 = 80.0; const GRID_SPACING: f32 = 8.0; /// View mode for the asset library #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum AssetViewMode { #[default] List, Grid, } /// Cache for thumbnail textures pub struct ThumbnailCache { /// Cached egui textures keyed by asset UUID textures: HashMap, /// Track which assets need regeneration dirty: HashSet, } impl Default for ThumbnailCache { fn default() -> Self { Self::new() } } impl ThumbnailCache { pub fn new() -> Self { Self { textures: HashMap::new(), dirty: HashSet::new(), } } /// Get a cached thumbnail or create one using the provided generator pub fn get_or_create( &mut self, ctx: &egui::Context, asset_id: Uuid, generator: F, ) -> Option<&egui::TextureHandle> where F: FnOnce() -> Option>, { // Check if we need to regenerate if self.dirty.contains(&asset_id) { self.textures.remove(&asset_id); self.dirty.remove(&asset_id); } // Return cached texture if available if self.textures.contains_key(&asset_id) { return self.textures.get(&asset_id); } // Generate new thumbnail if let Some(rgba_data) = generator() { let color_image = egui::ColorImage::from_rgba_unmultiplied( [THUMBNAIL_SIZE as usize, THUMBNAIL_SIZE as usize], &rgba_data, ); let texture = ctx.load_texture( format!("thumbnail_{}", asset_id), color_image, egui::TextureOptions::LINEAR, ); self.textures.insert(asset_id, texture); return self.textures.get(&asset_id); } None } /// Check if a thumbnail is already cached (and not dirty) #[allow(dead_code)] pub fn has(&self, asset_id: &Uuid) -> bool { self.textures.contains_key(asset_id) && !self.dirty.contains(asset_id) } /// Mark an asset's thumbnail as needing regeneration pub fn invalidate(&mut self, asset_id: &Uuid) { self.dirty.insert(*asset_id); } } // ============================================================================ // Thumbnail Generation Functions // ============================================================================ /// Generate a 64x64 RGBA thumbnail for an image asset fn generate_image_thumbnail(asset: &lightningbeam_core::clip::ImageAsset) -> Option> { let data = asset.data.as_ref()?; // Decode the image let img = image::load_from_memory(data).ok()?; // Resize to thumbnail size using Lanczos3 filter for quality let thumbnail = img.resize_exact( THUMBNAIL_SIZE, THUMBNAIL_SIZE, image::imageops::FilterType::Lanczos3, ); // Convert to RGBA8 Some(thumbnail.to_rgba8().into_raw()) } /// Generate a placeholder thumbnail with a solid color and optional icon indication fn generate_placeholder_thumbnail(category: AssetCategory, bg_alpha: u8) -> Vec { let size = THUMBNAIL_SIZE as usize; let mut rgba = vec![0u8; size * size * 4]; // Get category color for the placeholder let color = category.color(); // Fill with semi-transparent background for pixel in rgba.chunks_mut(4) { pixel[0] = 40; pixel[1] = 40; pixel[2] = 40; pixel[3] = bg_alpha; } // Draw a simple icon/indicator in the center based on category let center = size / 2; let icon_size = size / 3; match category { AssetCategory::Video => { // Draw a play triangle for y in 0..icon_size { let row_width = (y * icon_size / icon_size).max(1); for x in 0..row_width { let px = center - icon_size / 4 + x; let py = center - icon_size / 2 + y; if px < size && py < size { let idx = (py * size + px) * 4; rgba[idx] = color.r(); rgba[idx + 1] = color.g(); rgba[idx + 2] = color.b(); rgba[idx + 3] = 255; } } } } _ => { // Draw a simple rectangle let half = icon_size / 2; for y in (center - half)..(center + half) { for x in (center - half)..(center + half) { if x < size && y < size { let idx = (y * size + x) * 4; rgba[idx] = color.r(); rgba[idx + 1] = color.g(); rgba[idx + 2] = color.b(); rgba[idx + 3] = 200; } } } } } rgba } /// Helper function to fill a thumbnail buffer with a background color fn fill_thumbnail_background(rgba: &mut [u8], color: egui::Color32) { for pixel in rgba.chunks_mut(4) { pixel[0] = color.r(); pixel[1] = color.g(); pixel[2] = color.b(); pixel[3] = color.a(); } } /// Helper function to draw a rectangle on the thumbnail buffer fn draw_thumbnail_rect( rgba: &mut [u8], width: usize, x: usize, y: usize, w: usize, h: usize, color: egui::Color32, ) { for dy in 0..h { for dx in 0..w { let px = x + dx; let py = y + dy; if px < width && py < width { let idx = (py * width + px) * 4; if idx + 3 < rgba.len() { rgba[idx] = color.r(); rgba[idx + 1] = color.g(); rgba[idx + 2] = color.b(); rgba[idx + 3] = color.a(); } } } } } /// Generate a waveform thumbnail for sampled audio /// Shows the first THUMBNAIL_PREVIEW_SECONDS of audio to avoid solid blobs for long clips fn generate_waveform_thumbnail( waveform_peaks: &[(f32, f32)], // (min, max) pairs bg_color: egui::Color32, wave_color: egui::Color32, ) -> Vec { let size = THUMBNAIL_SIZE as usize; let mut rgba = vec![0u8; size * size * 4]; // Fill background fill_thumbnail_background(&mut rgba, bg_color); // Draw waveform let center_y = size / 2; let _num_peaks = waveform_peaks.len().min(size); for (x, &(min_val, max_val)) in waveform_peaks.iter().take(size).enumerate() { // Scale peaks to pixel range (center ± half height) let min_y = (center_y as f32 + min_val * center_y as f32) as usize; let max_y = (center_y as f32 + max_val * center_y as f32) as usize; let y_start = min_y.min(max_y).min(size - 1); let y_end = min_y.max(max_y).min(size - 1); for y in y_start..=y_end { let idx = (y * size + x) * 4; if idx + 3 < rgba.len() { rgba[idx] = wave_color.r(); rgba[idx + 1] = wave_color.g(); rgba[idx + 2] = wave_color.b(); rgba[idx + 3] = 255; } } } rgba } /// Generate a video thumbnail by decoding the first frame /// Returns a 64x64 RGBA thumbnail with letterboxing to maintain aspect ratio fn generate_video_thumbnail( clip_id: &uuid::Uuid, video_manager: &std::sync::Arc>, ) -> Option> { // Get a frame from the video (at 1 second to skip potential black intros) let timestamp = 1.0; let frame = { let mut video_mgr = video_manager.lock().ok()?; video_mgr.get_frame(clip_id, timestamp)? }; let src_width = frame.width as usize; let src_height = frame.height as usize; let dst_size = THUMBNAIL_SIZE as usize; // Calculate letterboxing dimensions to maintain aspect ratio let src_aspect = src_width as f32 / src_height as f32; let (scaled_width, scaled_height, offset_x, offset_y) = if src_aspect > 1.0 { // Wide video - letterbox top and bottom let scaled_width = dst_size; let scaled_height = (dst_size as f32 / src_aspect) as usize; let offset_y = (dst_size - scaled_height) / 2; (scaled_width, scaled_height, 0, offset_y) } else { // Tall video - letterbox left and right let scaled_height = dst_size; let scaled_width = (dst_size as f32 * src_aspect) as usize; let offset_x = (dst_size - scaled_width) / 2; (scaled_width, scaled_height, offset_x, 0) }; // Create thumbnail with black letterbox bars let mut rgba = vec![0u8; dst_size * dst_size * 4]; let x_ratio = src_width as f32 / scaled_width as f32; let y_ratio = src_height as f32 / scaled_height as f32; // Fill the scaled region for dst_y in 0..scaled_height { for dst_x in 0..scaled_width { let src_x = (dst_x as f32 * x_ratio) as usize; let src_y = (dst_y as f32 * y_ratio) as usize; let src_idx = (src_y * src_width + src_x) * 4; let final_x = dst_x + offset_x; let final_y = dst_y + offset_y; let dst_idx = (final_y * dst_size + final_x) * 4; // Copy RGBA bytes if src_idx + 3 < frame.rgba_data.len() && dst_idx + 3 < rgba.len() { rgba[dst_idx] = frame.rgba_data[src_idx]; rgba[dst_idx + 1] = frame.rgba_data[src_idx + 1]; rgba[dst_idx + 2] = frame.rgba_data[src_idx + 2]; rgba[dst_idx + 3] = frame.rgba_data[src_idx + 3]; } } } Some(rgba) } /// Generate a piano roll thumbnail for MIDI clips /// Shows notes as horizontal bars with Y position = note % 12 (one octave) fn generate_midi_thumbnail( events: &[(f64, u8, u8, bool)], // (timestamp, note_number, velocity, is_note_on) duration: f64, bg_color: egui::Color32, note_color: egui::Color32, ) -> Vec { let size = THUMBNAIL_SIZE as usize; let mut rgba = vec![0u8; size * size * 4]; // Fill background fill_thumbnail_background(&mut rgba, bg_color); // Limit to first 10 seconds let preview_duration = duration.min(THUMBNAIL_PREVIEW_SECONDS); if preview_duration <= 0.0 { return rgba; } // Draw note events for &(timestamp, note_number, _velocity, is_note_on) in events { if !is_note_on || timestamp > preview_duration { continue; } let x = ((timestamp / preview_duration) * size as f64) as usize; // Note position: modulo 12 (one octave), mapped to full height // Note 0 (C) at bottom, Note 11 (B) at top let note_in_octave = note_number % 12; let y = size - 1 - (note_in_octave as usize * size / 12); // Draw a small rectangle for the note draw_thumbnail_rect(&mut rgba, size, x.min(size - 2), y.saturating_sub(2), 2, 4, note_color); } rgba } /// Generate a 64x64 RGBA thumbnail for a vector clip /// Renders frame 0 of the clip using tiny-skia for software rendering fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec { use kurbo::PathEl; use tiny_skia::{Paint, PathBuilder, Pixmap, Transform as TsTransform}; let size = THUMBNAIL_SIZE as usize; let mut pixmap = Pixmap::new(THUMBNAIL_SIZE, THUMBNAIL_SIZE) .unwrap_or_else(|| Pixmap::new(1, 1).unwrap()); // Fill background pixmap.fill(tiny_skia::Color::from_rgba8( bg_color.r(), bg_color.g(), bg_color.b(), bg_color.a(), )); // Calculate scale to fit clip dimensions into thumbnail let scale_x = THUMBNAIL_SIZE as f64 / clip.width.max(1.0); let scale_y = THUMBNAIL_SIZE as f64 / clip.height.max(1.0); let scale = scale_x.min(scale_y) * 0.9; // 90% to leave a small margin // Center offset let offset_x = (THUMBNAIL_SIZE as f64 - clip.width * scale) / 2.0; let offset_y = (THUMBNAIL_SIZE as f64 - clip.height * scale) / 2.0; // Iterate through layers and render shapes for layer_node in clip.layers.iter() { if let AnyLayer::Vector(vector_layer) = &layer_node.data { // Render each shape at time 0.0 (frame 0) for shape in vector_layer.shapes_at_time(0.0) { // Get the path (frame 0) let kurbo_path = shape.path(); // Convert kurbo BezPath to tiny-skia PathBuilder let mut path_builder = PathBuilder::new(); for el in kurbo_path.iter() { match el { PathEl::MoveTo(p) => { let x = (p.x * scale + offset_x) as f32; let y = (p.y * scale + offset_y) as f32; path_builder.move_to(x, y); } PathEl::LineTo(p) => { let x = (p.x * scale + offset_x) as f32; let y = (p.y * scale + offset_y) as f32; path_builder.line_to(x, y); } PathEl::QuadTo(p1, p2) => { let x1 = (p1.x * scale + offset_x) as f32; let y1 = (p1.y * scale + offset_y) as f32; let x2 = (p2.x * scale + offset_x) as f32; let y2 = (p2.y * scale + offset_y) as f32; path_builder.quad_to(x1, y1, x2, y2); } PathEl::CurveTo(p1, p2, p3) => { let x1 = (p1.x * scale + offset_x) as f32; let y1 = (p1.y * scale + offset_y) as f32; let x2 = (p2.x * scale + offset_x) as f32; let y2 = (p2.y * scale + offset_y) as f32; let x3 = (p3.x * scale + offset_x) as f32; let y3 = (p3.y * scale + offset_y) as f32; path_builder.cubic_to(x1, y1, x2, y2, x3, y3); } PathEl::ClosePath => { path_builder.close(); } } } if let Some(ts_path) = path_builder.finish() { // Draw fill if present if let Some(fill_color) = &shape.fill_color { let mut paint = Paint::default(); paint.set_color(shape_color_to_tiny_skia(fill_color)); paint.anti_alias = true; pixmap.fill_path( &ts_path, &paint, tiny_skia::FillRule::Winding, TsTransform::identity(), None, ); } // Draw stroke if present if let Some(stroke_color) = &shape.stroke_color { if let Some(stroke_style) = &shape.stroke_style { let mut paint = Paint::default(); paint.set_color(shape_color_to_tiny_skia(stroke_color)); paint.anti_alias = true; let stroke = tiny_skia::Stroke { width: (stroke_style.width * scale) as f32, ..Default::default() }; pixmap.stroke_path( &ts_path, &paint, &stroke, TsTransform::identity(), None, ); } } } } } } // Convert to RGBA bytes let data = pixmap.data(); // tiny-skia uses premultiplied RGBA, need to convert to straight alpha for egui let mut rgba = Vec::with_capacity(size * size * 4); for chunk in data.chunks(4) { let a = chunk[3] as f32 / 255.0; if a > 0.0 { // Unpremultiply rgba.push((chunk[0] as f32 / a).min(255.0) as u8); rgba.push((chunk[1] as f32 / a).min(255.0) as u8); rgba.push((chunk[2] as f32 / a).min(255.0) as u8); rgba.push(chunk[3]); } else { rgba.extend_from_slice(chunk); } } rgba } /// Convert ShapeColor to tiny_skia Color fn shape_color_to_tiny_skia(color: &ShapeColor) -> tiny_skia::Color { tiny_skia::Color::from_rgba8(color.r, color.g, color.b, color.a) } /// Generate a simple effect thumbnail with a pink gradient #[allow(dead_code)] fn generate_effect_thumbnail() -> Vec { let size = THUMBNAIL_SIZE as usize; let mut rgba = vec![0u8; size * size * 4]; // Pink gradient background with "FX" visual indicator for y in 0..size { for x in 0..size { let brightness = 1.0 - (y as f32 / size as f32) * 0.3; let idx = (y * size + x) * 4; rgba[idx] = (220.0 * brightness) as u8; // R rgba[idx + 1] = (80.0 * brightness) as u8; // G rgba[idx + 2] = (160.0 * brightness) as u8; // B rgba[idx + 3] = 200; // A } } // Draw a simple "FX" pattern in the center using darker pixels let center = size / 2; let letter_size = size / 4; // Draw "F" - vertical bar for y in (center - letter_size)..(center + letter_size) { let x = center - letter_size; let idx = (y * size + x) * 4; rgba[idx] = 255; rgba[idx + 1] = 255; rgba[idx + 2] = 255; rgba[idx + 3] = 255; } // Draw "F" - top horizontal for x in (center - letter_size)..(center - 2) { let y = center - letter_size; let idx = (y * size + x) * 4; rgba[idx] = 255; rgba[idx + 1] = 255; rgba[idx + 2] = 255; rgba[idx + 3] = 255; } // Draw "F" - middle horizontal for x in (center - letter_size)..(center - 4) { let y = center; let idx = (y * size + x) * 4; rgba[idx] = 255; rgba[idx + 1] = 255; rgba[idx + 2] = 255; rgba[idx + 3] = 255; } // Draw "X" - diagonal lines for i in 0..letter_size { // Top-left to bottom-right let x1 = center + 2 + i; let y1 = center - letter_size + i * 2; if x1 < size && y1 < size { let idx = (y1 * size + x1) * 4; rgba[idx] = 255; rgba[idx + 1] = 255; rgba[idx + 2] = 255; rgba[idx + 3] = 255; } // Top-right to bottom-left let x2 = center + letter_size - i; let y2 = center - letter_size + i * 2; if x2 < size && y2 < size { let idx = (y2 * size + x2) * 4; rgba[idx] = 255; rgba[idx + 1] = 255; rgba[idx + 2] = 255; rgba[idx + 3] = 255; } } rgba } /// Ellipsize a string to fit within a maximum character count #[allow(dead_code)] fn ellipsize(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { s.to_string() } else { let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect(); format!("{}...", truncated) } } /// Asset category for filtering #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssetCategory { All, Vector, Video, Audio, Images, Effects, } impl AssetCategory { pub fn display_name(&self) -> &'static str { match self { AssetCategory::All => "All", AssetCategory::Vector => "Vector", AssetCategory::Video => "Video", AssetCategory::Audio => "Audio", AssetCategory::Images => "Images", AssetCategory::Effects => "Effects", } } pub fn all() -> &'static [AssetCategory] { &[ AssetCategory::All, AssetCategory::Vector, AssetCategory::Video, AssetCategory::Audio, AssetCategory::Images, AssetCategory::Effects, ] } /// Get the color associated with this category pub fn color(&self) -> egui::Color32 { match self { AssetCategory::All => egui::Color32::from_gray(150), AssetCategory::Vector => egui::Color32::from_rgb(100, 150, 255), // Blue AssetCategory::Video => egui::Color32::from_rgb(255, 150, 100), // Orange AssetCategory::Audio => egui::Color32::from_rgb(100, 255, 150), // Green AssetCategory::Images => egui::Color32::from_rgb(255, 200, 100), // Yellow/Gold AssetCategory::Effects => egui::Color32::from_rgb(220, 80, 160), // Pink } } } /// Unified asset entry for display #[derive(Debug, Clone)] pub struct AssetEntry { pub id: Uuid, pub name: String, pub category: AssetCategory, /// More specific clip type for drag-and-drop compatibility pub drag_clip_type: DragClipType, pub duration: f64, pub dimensions: Option<(f64, f64)>, pub extra_info: String, /// True for built-in effects from the registry (not editable/deletable) pub is_builtin: bool, /// Folder this asset belongs to (None = root) pub folder_id: Option, } /// Folder entry for display #[derive(Debug, Clone)] pub struct FolderEntry { pub id: Uuid, pub name: String, #[allow(dead_code)] pub category: AssetCategory, pub item_count: usize, } /// Library item - either a folder or an asset #[derive(Debug, Clone)] pub enum LibraryItem { Folder(FolderEntry), Asset(AssetEntry), } impl LibraryItem { #[allow(dead_code)] pub fn id(&self) -> Uuid { match self { LibraryItem::Folder(f) => f.id, LibraryItem::Asset(a) => a.id, } } pub fn name(&self) -> &str { match self { LibraryItem::Folder(f) => &f.name, LibraryItem::Asset(a) => &a.name, } } } /// Pending delete confirmation state #[derive(Debug, Clone)] struct PendingDelete { asset_id: Uuid, asset_name: String, category: AssetCategory, in_use: bool, } /// Inline rename editing state #[derive(Debug, Clone)] struct RenameState { asset_id: Uuid, category: AssetCategory, edit_text: String, } /// Inline folder rename editing state #[derive(Debug, Clone)] struct FolderRenameState { folder_id: Uuid, category: AssetCategory, edit_text: String, } /// Context menu state with position #[derive(Debug, Clone)] struct ContextMenuState { asset_id: Uuid, position: egui::Pos2, } #[derive(Debug, Clone)] struct FolderContextMenuState { folder_id: Uuid, position: egui::Pos2, } pub struct AssetLibraryPane { /// Current search filter text search_filter: String, /// Currently selected category tab selected_category: AssetCategory, /// Currently selected asset ID (for future drag-to-timeline) selected_asset: Option, /// Context menu state with position (for assets) context_menu: Option, /// Folder context menu state (for folders) folder_context_menu: Option, /// Pane context menu position (for background right-click) pane_context_menu: Option, /// Pending delete confirmation pending_delete: Option, /// Active rename state (for assets) rename_state: Option, /// Active folder rename state folder_rename_state: Option, /// Current view mode (list or grid) view_mode: AssetViewMode, /// Thumbnail texture cache thumbnail_cache: ThumbnailCache, /// Current folder navigation per category (category index -> current folder ID) /// None means at root level current_folders: HashMap>, /// Set of expanded folder IDs (for tree view - future enhancement) #[allow(dead_code)] expanded_folders: HashSet, /// Cached folder icon texture folder_icon: Option, } // Embedded folder icon SVG const FOLDER_ICON_SVG: &[u8] = include_bytes!("../../../../src/assets/folder.svg"); impl AssetLibraryPane { pub fn new() -> Self { Self { search_filter: String::new(), selected_category: AssetCategory::All, selected_asset: None, context_menu: None, folder_context_menu: None, pane_context_menu: None, pending_delete: None, rename_state: None, folder_rename_state: None, view_mode: AssetViewMode::default(), thumbnail_cache: ThumbnailCache::new(), current_folders: HashMap::new(), expanded_folders: HashSet::new(), folder_icon: None, } } /// Get or load the folder icon texture fn get_folder_icon(&mut self, ctx: &egui::Context) -> Option<&egui::TextureHandle> { if self.folder_icon.is_none() { // Rasterize the embedded SVG let render_size = 32; // Render at 32px for list/grid views if let Ok(tree) = resvg::usvg::Tree::from_data(FOLDER_ICON_SVG, &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); if let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new(render_size, render_size) { let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); resvg::render(&tree, transform, &mut pixmap.as_mut()); 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); let texture = ctx.load_texture( "folder_icon", color_image, egui::TextureOptions::LINEAR, ); self.folder_icon = Some(texture); } } } self.folder_icon.as_ref() } /// Get the current folder for the selected category fn get_current_folder(&self) -> Option { let category_index = match self.selected_category { AssetCategory::All => return None, // All category doesn't have folders AssetCategory::Vector => 1, AssetCategory::Video => 2, AssetCategory::Audio => 3, AssetCategory::Images => 4, AssetCategory::Effects => 5, }; self.current_folders.get(&category_index).copied().flatten() } /// Set the current folder for the selected category fn set_current_folder(&mut self, folder_id: Option) { let category_index = match self.selected_category { AssetCategory::All => return, // All category doesn't have folders AssetCategory::Vector => 1, AssetCategory::Video => 2, AssetCategory::Audio => 3, AssetCategory::Images => 4, AssetCategory::Effects => 5, }; self.current_folders.insert(category_index, folder_id); } /// Convert UI AssetCategory to core AssetCategory fn to_core_category(category: AssetCategory) -> Option { match category { AssetCategory::All => None, AssetCategory::Vector => Some(lightningbeam_core::document::AssetCategory::Vector), AssetCategory::Video => Some(lightningbeam_core::document::AssetCategory::Video), AssetCategory::Audio => Some(lightningbeam_core::document::AssetCategory::Audio), AssetCategory::Images => Some(lightningbeam_core::document::AssetCategory::Images), AssetCategory::Effects => Some(lightningbeam_core::document::AssetCategory::Effects), } } /// Convert DragClipType to core AssetCategory fn drag_clip_type_to_core_category(clip_type: DragClipType) -> lightningbeam_core::document::AssetCategory { match clip_type { DragClipType::Vector => lightningbeam_core::document::AssetCategory::Vector, DragClipType::Video => lightningbeam_core::document::AssetCategory::Video, DragClipType::AudioSampled | DragClipType::AudioMidi => lightningbeam_core::document::AssetCategory::Audio, DragClipType::Image => lightningbeam_core::document::AssetCategory::Images, DragClipType::Effect => lightningbeam_core::document::AssetCategory::Effects, } } /// Convert DragClipType to UI AssetCategory fn drag_clip_type_to_category(clip_type: DragClipType) -> AssetCategory { match clip_type { DragClipType::Vector => AssetCategory::Vector, DragClipType::Video => AssetCategory::Video, DragClipType::AudioSampled | DragClipType::AudioMidi => AssetCategory::Audio, DragClipType::Image => AssetCategory::Images, DragClipType::Effect => AssetCategory::Effects, } } /// Collect all assets from the document into a unified list fn collect_assets(&self, document: &Document) -> Vec { let mut assets = Vec::new(); // Collect vector clips for (id, clip) in &document.vector_clips { assets.push(AssetEntry { id: *id, name: clip.name.clone(), category: AssetCategory::Vector, drag_clip_type: DragClipType::Vector, duration: clip.duration, dimensions: Some((clip.width, clip.height)), extra_info: format!("{}x{}", clip.width as u32, clip.height as u32), is_builtin: false, folder_id: clip.folder_id, }); } // Collect video clips for (id, clip) in &document.video_clips { assets.push(AssetEntry { id: *id, name: clip.name.clone(), category: AssetCategory::Video, drag_clip_type: DragClipType::Video, duration: clip.duration, dimensions: Some((clip.width, clip.height)), extra_info: format!("{:.0}fps", clip.frame_rate), is_builtin: false, folder_id: clip.folder_id, }); } // Build set of audio clip IDs that are linked to videos let linked_audio_ids: std::collections::HashSet = document.video_clips.values() .filter_map(|video| video.linked_audio_clip_id) .collect(); // Collect audio clips (skip those linked to videos) for (id, clip) in &document.audio_clips { // Skip if this audio clip is linked to a video if linked_audio_ids.contains(id) { continue; } let (extra_info, drag_clip_type) = match &clip.clip_type { AudioClipType::Sampled { .. } => ("Sampled".to_string(), DragClipType::AudioSampled), AudioClipType::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi), AudioClipType::Recording => { // Skip recording-in-progress clips from asset library continue; } }; assets.push(AssetEntry { id: *id, name: clip.name.clone(), category: AssetCategory::Audio, drag_clip_type, duration: clip.duration, dimensions: None, extra_info, is_builtin: false, folder_id: clip.folder_id, }); } // Collect image assets for (id, asset) in &document.image_assets { assets.push(AssetEntry { id: *id, name: asset.name.clone(), category: AssetCategory::Images, drag_clip_type: DragClipType::Image, duration: 0.0, // Images don't have duration dimensions: Some((asset.width as f64, asset.height as f64)), extra_info: format!("{}x{}", asset.width, asset.height), is_builtin: false, folder_id: asset.folder_id, }); } // Collect built-in effects from registry for effect_def in lightningbeam_core::effect_registry::EffectRegistry::get_all() { assets.push(AssetEntry { id: effect_def.id, name: effect_def.name.clone(), category: AssetCategory::Effects, drag_clip_type: DragClipType::Effect, duration: 5.0, // Default duration when dropped dimensions: None, extra_info: format!("{:?}", effect_def.category), is_builtin: true, // Built-in from registry folder_id: None, // Built-in effects are at root }); } // Collect user-edited effects from document (that aren't in registry) let registry_ids: HashSet = lightningbeam_core::effect_registry::EffectRegistry::get_all() .iter() .map(|e| e.id) .collect(); for effect_def in document.effect_definitions.values() { if !registry_ids.contains(&effect_def.id) { // User-created/modified effect assets.push(AssetEntry { id: effect_def.id, name: effect_def.name.clone(), category: AssetCategory::Effects, drag_clip_type: DragClipType::Effect, duration: 5.0, dimensions: None, extra_info: format!("{:?}", effect_def.category), is_builtin: false, // User effect folder_id: effect_def.folder_id, }); } } // Sort alphabetically by name assets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); assets } /// Collect folders and assets for the current view (folder-aware) fn collect_items(&self, document: &Document) -> Vec { let mut items = Vec::new(); // For "All" category, return all assets except built-in effects (no folders) if self.selected_category == AssetCategory::All { let assets = self.collect_assets(document); return assets.into_iter() .filter(|asset| { // Exclude built-in effects from "All" category !(asset.category == AssetCategory::Effects && asset.is_builtin) }) .map(LibraryItem::Asset) .collect(); } // Get the core category and folder tree let Some(core_category) = Self::to_core_category(self.selected_category) else { return items; }; let folder_tree = document.get_folder_tree(core_category); let current_folder = self.get_current_folder(); // Collect folders at the current level let folders = if let Some(parent_id) = current_folder { folder_tree.children_of(&parent_id) } else { folder_tree.root_folders() }; for folder in folders { // Count items in this folder (subfolders + assets) let subfolder_count = folder_tree.children_of(&folder.id).len(); // Count assets in this folder let asset_count = match self.selected_category { AssetCategory::Vector => document .vector_clips .values() .filter(|c| c.folder_id == Some(folder.id)) .count(), AssetCategory::Video => document .video_clips .values() .filter(|c| c.folder_id == Some(folder.id)) .count(), AssetCategory::Audio => document .audio_clips .values() .filter(|c| c.folder_id == Some(folder.id)) .count(), AssetCategory::Images => document .image_assets .values() .filter(|a| a.folder_id == Some(folder.id)) .count(), AssetCategory::Effects => document .effect_definitions .values() .filter(|e| e.folder_id == Some(folder.id)) .count(), AssetCategory::All => 0, }; items.push(LibraryItem::Folder(FolderEntry { id: folder.id, name: folder.name.clone(), category: self.selected_category, item_count: subfolder_count + asset_count, })); } // Collect assets at the current level match self.selected_category { AssetCategory::Vector => { for (id, clip) in &document.vector_clips { if clip.folder_id == current_folder { items.push(LibraryItem::Asset(AssetEntry { id: *id, name: clip.name.clone(), category: AssetCategory::Vector, drag_clip_type: DragClipType::Vector, duration: clip.duration, dimensions: Some((clip.width, clip.height)), extra_info: format!("{}x{}", clip.width as u32, clip.height as u32), is_builtin: false, folder_id: clip.folder_id, })); } } } AssetCategory::Video => { for (id, clip) in &document.video_clips { if clip.folder_id == current_folder { items.push(LibraryItem::Asset(AssetEntry { id: *id, name: clip.name.clone(), category: AssetCategory::Video, drag_clip_type: DragClipType::Video, duration: clip.duration, dimensions: Some((clip.width, clip.height)), extra_info: format!("{:.0}fps", clip.frame_rate), is_builtin: false, folder_id: clip.folder_id, })); } } } AssetCategory::Audio => { // Build set of linked audio IDs to skip let linked_audio_ids: HashSet = document .video_clips .values() .filter_map(|v| v.linked_audio_clip_id) .collect(); for (id, clip) in &document.audio_clips { if !linked_audio_ids.contains(id) && clip.folder_id == current_folder { let (extra_info, drag_clip_type) = match &clip.clip_type { AudioClipType::Sampled { .. } => { ("Sampled".to_string(), DragClipType::AudioSampled) } AudioClipType::Midi { .. } => { ("MIDI".to_string(), DragClipType::AudioMidi) } AudioClipType::Recording => { // Skip recording-in-progress clips continue; } }; items.push(LibraryItem::Asset(AssetEntry { id: *id, name: clip.name.clone(), category: AssetCategory::Audio, drag_clip_type, duration: clip.duration, dimensions: None, extra_info, is_builtin: false, folder_id: clip.folder_id, })); } } } AssetCategory::Images => { for (id, asset) in &document.image_assets { if asset.folder_id == current_folder { items.push(LibraryItem::Asset(AssetEntry { id: *id, name: asset.name.clone(), category: AssetCategory::Images, drag_clip_type: DragClipType::Image, duration: 0.0, dimensions: Some((asset.width as f64, asset.height as f64)), extra_info: format!("{}x{}", asset.width, asset.height), is_builtin: false, folder_id: asset.folder_id, })); } } } AssetCategory::Effects => { // Built-in effects always appear at root level if current_folder.is_none() { for effect_def in lightningbeam_core::effect_registry::EffectRegistry::get_all() { items.push(LibraryItem::Asset(AssetEntry { id: effect_def.id, name: effect_def.name.clone(), category: AssetCategory::Effects, drag_clip_type: DragClipType::Effect, duration: 0.0, dimensions: None, extra_info: format!("{:?}", effect_def.category), is_builtin: true, folder_id: None, // Built-in effects are always at root })); } } // User effects for (id, effect) in &document.effect_definitions { if effect.folder_id == current_folder { items.push(LibraryItem::Asset(AssetEntry { id: *id, name: effect.name.clone(), category: AssetCategory::Effects, drag_clip_type: DragClipType::Effect, duration: 0.0, dimensions: None, extra_info: format!("{:?}", effect.category), is_builtin: false, folder_id: effect.folder_id, })); } } } AssetCategory::All => { // Already handled above } } // Sort: folders first (alphabetically), then assets (alphabetically) items.sort_by(|a, b| { match (a, b) { (LibraryItem::Folder(f1), LibraryItem::Folder(f2)) => { f1.name.to_lowercase().cmp(&f2.name.to_lowercase()) } (LibraryItem::Asset(a1), LibraryItem::Asset(a2)) => { a1.name.to_lowercase().cmp(&a2.name.to_lowercase()) } (LibraryItem::Folder(_), LibraryItem::Asset(_)) => std::cmp::Ordering::Less, (LibraryItem::Asset(_), LibraryItem::Folder(_)) => std::cmp::Ordering::Greater, } }); items } /// Filter assets based on current category and search text #[allow(dead_code)] fn filter_assets<'a>(&self, assets: &'a [AssetEntry]) -> Vec<&'a AssetEntry> { let search_lower = self.search_filter.to_lowercase(); assets .iter() .filter(|asset| { // Category filter let category_matches = if self.selected_category == AssetCategory::All { // "All" tab: show everything EXCEPT built-in effects // (built-in effects only appear in the Effects tab) !(asset.category == AssetCategory::Effects && asset.is_builtin) } else { asset.category == self.selected_category }; // Search filter let search_matches = search_lower.is_empty() || asset.name.to_lowercase().contains(&search_lower); category_matches && search_matches }) .collect() } /// Check if an asset is currently in use (has clip instances on layers) fn is_asset_in_use(document: &Document, asset_id: Uuid, category: AssetCategory) -> bool { // Check all layers (root + inside movie clips) for clip instances referencing this asset for layer in document.all_layers() { match layer { lightningbeam_core::layer::AnyLayer::Vector(vl) => { if category == AssetCategory::Vector { for instance in &vl.clip_instances { if instance.clip_id == asset_id { return true; } } } } lightningbeam_core::layer::AnyLayer::Video(vl) => { if category == AssetCategory::Video { for instance in &vl.clip_instances { if instance.clip_id == asset_id { return true; } } } } lightningbeam_core::layer::AnyLayer::Audio(al) => { if category == AssetCategory::Audio { for instance in &al.clip_instances { if instance.clip_id == asset_id { return true; } } } } lightningbeam_core::layer::AnyLayer::Effect(el) => { if category == AssetCategory::Effects { for instance in &el.clip_instances { if instance.clip_id == asset_id { return true; } } } } } } false } /// Delete an asset from the document fn delete_asset(document: &mut Document, asset_id: Uuid, category: AssetCategory) { match category { AssetCategory::Vector => { document.remove_vector_clip(&asset_id); } AssetCategory::Video => { document.remove_video_clip(&asset_id); } AssetCategory::Audio => { document.remove_audio_clip(&asset_id); } AssetCategory::Images => { document.remove_image_asset(&asset_id); } AssetCategory::Effects => { document.effect_definitions.remove(&asset_id); } AssetCategory::All => {} // Not a real category for deletion } } /// Rename an asset in the document fn rename_asset(document: &mut Document, asset_id: Uuid, category: AssetCategory, new_name: &str) { match category { AssetCategory::Vector => { if let Some(clip) = document.get_vector_clip_mut(&asset_id) { clip.name = new_name.to_string(); } } AssetCategory::Video => { if let Some(clip) = document.get_video_clip_mut(&asset_id) { clip.name = new_name.to_string(); } } AssetCategory::Audio => { if let Some(clip) = document.get_audio_clip_mut(&asset_id) { clip.name = new_name.to_string(); } } AssetCategory::Images => { if let Some(asset) = document.get_image_asset_mut(&asset_id) { asset.name = new_name.to_string(); } } AssetCategory::Effects => { if let Some(effect) = document.effect_definitions.get_mut(&asset_id) { effect.name = new_name.to_string(); } } AssetCategory::All => {} // Not a real category for renaming } } /// Render the search bar at the top with view toggle buttons fn render_search_bar(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &SharedPaneState) { let search_rect = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), SEARCH_BAR_HEIGHT)); // Background let bg_style = shared.theme.style(".panel-header", ui.ctx()); let bg_color = bg_style .background_color .unwrap_or(egui::Color32::from_rgb(30, 30, 30)); ui.painter().rect_filled(search_rect, 0.0, bg_color); // View toggle buttons on the right (list and grid icons) let button_size = 20.0; let button_padding = 4.0; let buttons_width = button_size * 2.0 + button_padding * 3.0; // Grid view button (rightmost) let grid_button_rect = egui::Rect::from_min_size( egui::pos2( search_rect.max.x - button_size - button_padding, search_rect.min.y + (SEARCH_BAR_HEIGHT - button_size) / 2.0, ), egui::vec2(button_size, button_size), ); // List view button let list_button_rect = egui::Rect::from_min_size( egui::pos2( grid_button_rect.min.x - button_size - button_padding, search_rect.min.y + (SEARCH_BAR_HEIGHT - button_size) / 2.0, ), egui::vec2(button_size, button_size), ); // Draw and handle list button let list_selected = self.view_mode == AssetViewMode::List; let list_response = ui.allocate_rect(list_button_rect, egui::Sense::click()); let list_bg = if list_selected { egui::Color32::from_rgb(70, 90, 110) } else if list_response.hovered() { egui::Color32::from_rgb(50, 50, 50) } else { egui::Color32::TRANSPARENT }; ui.painter().rect_filled(list_button_rect, 3.0, list_bg); // Draw list icon (three horizontal lines) let list_icon_color = if list_selected { egui::Color32::WHITE } else { egui::Color32::from_gray(150) }; let line_spacing = 4.0; let line_width = 10.0; let line_x = list_button_rect.center().x - line_width / 2.0; for i in 0..3 { let line_y = list_button_rect.center().y - line_spacing + (i as f32 * line_spacing); ui.painter().line_segment( [ egui::pos2(line_x, line_y), egui::pos2(line_x + line_width, line_y), ], egui::Stroke::new(1.5, list_icon_color), ); } if list_response.clicked() { self.view_mode = AssetViewMode::List; } // Draw and handle grid button let grid_selected = self.view_mode == AssetViewMode::Grid; let grid_response = ui.allocate_rect(grid_button_rect, egui::Sense::click()); let grid_bg = if grid_selected { egui::Color32::from_rgb(70, 90, 110) } else if grid_response.hovered() { egui::Color32::from_rgb(50, 50, 50) } else { egui::Color32::TRANSPARENT }; ui.painter().rect_filled(grid_button_rect, 3.0, grid_bg); // Draw grid icon (2x2 squares) let grid_icon_color = if grid_selected { egui::Color32::WHITE } else { egui::Color32::from_gray(150) }; let square_size = 4.0; let square_gap = 2.0; let grid_start_x = grid_button_rect.center().x - square_size - square_gap / 2.0; let grid_start_y = grid_button_rect.center().y - square_size - square_gap / 2.0; for row in 0..2 { for col in 0..2 { let square_rect = egui::Rect::from_min_size( egui::pos2( grid_start_x + col as f32 * (square_size + square_gap), grid_start_y + row as f32 * (square_size + square_gap), ), egui::vec2(square_size, square_size), ); ui.painter().rect_filled(square_rect, 1.0, grid_icon_color); } } if grid_response.clicked() { self.view_mode = AssetViewMode::Grid; } // Label position let label_pos = search_rect.min + egui::vec2(8.0, (SEARCH_BAR_HEIGHT - 14.0) / 2.0); ui.painter().text( label_pos, egui::Align2::LEFT_TOP, "Search:", egui::FontId::proportional(14.0), egui::Color32::from_gray(180), ); // Text field using IME-safe widget (leave room for view toggle buttons) let text_edit_rect = egui::Rect::from_min_size( search_rect.min + egui::vec2(65.0, 4.0), egui::vec2(search_rect.width() - 75.0 - buttons_width, SEARCH_BAR_HEIGHT - 8.0), ); let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_edit_rect)); ImeTextField::new(&mut self.search_filter) .placeholder("Filter assets...") .desired_width(text_edit_rect.width()) .show(&mut child_ui); } /// Render category tabs fn render_category_tabs( &mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &SharedPaneState, ) { let tabs_rect = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), CATEGORY_TAB_HEIGHT)); // Background let bg_style = shared.theme.style(".panel-content", ui.ctx()); let bg_color = bg_style .background_color .unwrap_or(egui::Color32::from_rgb(40, 40, 40)); ui.painter().rect_filled(tabs_rect, 0.0, bg_color); // Tab buttons let tab_width = tabs_rect.width() / AssetCategory::all().len() as f32; for (i, category) in AssetCategory::all().iter().enumerate() { let tab_rect = egui::Rect::from_min_size( tabs_rect.min + egui::vec2(i as f32 * tab_width, 0.0), egui::vec2(tab_width, CATEGORY_TAB_HEIGHT), ); let is_selected = self.selected_category == *category; // Tab background let tab_bg = if is_selected { egui::Color32::from_rgb(60, 60, 60) } else { egui::Color32::TRANSPARENT }; ui.painter().rect_filled(tab_rect, 0.0, tab_bg); // Handle click let response = ui.allocate_rect(tab_rect, egui::Sense::click()); if response.clicked() { self.selected_category = *category; } // Category color indicator let indicator_color = category.color(); let text_color = if is_selected { indicator_color } else { egui::Color32::from_gray(150) }; ui.painter().text( tab_rect.center(), egui::Align2::CENTER_CENTER, category.display_name(), egui::FontId::proportional(12.0), text_color, ); // Underline for selected tab if is_selected { ui.painter().line_segment( [ egui::pos2(tab_rect.min.x + 4.0, tab_rect.max.y - 2.0), egui::pos2(tab_rect.max.x - 4.0, tab_rect.max.y - 2.0), ], egui::Stroke::new(2.0, indicator_color), ); } } } /// Render breadcrumb navigation showing current folder path fn render_breadcrumbs( &mut self, ui: &mut egui::Ui, rect: egui::Rect, document: &Document, shared: &SharedPaneState, ) { // Only show breadcrumbs for specific categories (not "All") if self.selected_category == AssetCategory::All { return; } let Some(core_category) = Self::to_core_category(self.selected_category) else { return; }; // Background let bg_style = shared.theme.style(".panel-header", ui.ctx()); let bg_color = bg_style .background_color .unwrap_or(egui::Color32::from_rgb(25, 25, 25)); ui.painter().rect_filled(rect, 0.0, bg_color); // Get folder tree and build path let folder_tree = document.get_folder_tree(core_category); let current_folder = self.get_current_folder(); // Build path: category name -> folder1 -> folder2 -> ... let mut path_items = vec![self.selected_category.display_name().to_string()]; let mut path_folder_ids = Vec::new(); if let Some(folder_id) = current_folder { let folder_path_ids = folder_tree.path_to_folder(&folder_id); for fid in &folder_path_ids { if let Some(folder) = folder_tree.folders.get(fid) { path_items.push(folder.name.clone()); path_folder_ids.push(*fid); } } } // Render breadcrumb items let mut x_offset = rect.min.x + 8.0; let y_center = rect.min.y + BREADCRUMB_HEIGHT / 2.0; for (i, item_name) in path_items.iter().enumerate() { let is_last = i == path_items.len() - 1; // Calculate text size let font_id = egui::FontId::proportional(12.0); let text_galley = ui.painter().layout_no_wrap( item_name.clone(), font_id.clone(), egui::Color32::WHITE, ); let text_width = text_galley.size().x; let item_rect = egui::Rect::from_min_size( egui::pos2(x_offset, rect.min.y), egui::vec2(text_width + 8.0, BREADCRUMB_HEIGHT), ); // Make clickable if not the last item let response = ui.allocate_rect(item_rect, egui::Sense::click()); // Determine color based on state let text_color = if is_last { egui::Color32::WHITE } else if response.hovered() { egui::Color32::from_rgb(100, 150, 255) } else { egui::Color32::from_rgb(150, 150, 150) }; // Draw text ui.painter().text( egui::pos2(x_offset, y_center), egui::Align2::LEFT_CENTER, item_name, font_id, text_color, ); // Handle click to navigate up the hierarchy if response.clicked() && !is_last { if i == 0 { // Clicked on category root - go to root self.set_current_folder(None); } else { // Clicked on a folder - navigate to it // Get the folder at this index (i-1 because category is at 0) if i - 1 < path_folder_ids.len() { self.set_current_folder(Some(path_folder_ids[i - 1])); } } } x_offset += text_width + 8.0; // Draw separator (>) if not last if !is_last { ui.painter().text( egui::pos2(x_offset, y_center), egui::Align2::LEFT_CENTER, ">", egui::FontId::proportional(12.0), egui::Color32::from_rgb(100, 100, 100), ); x_offset += 16.0; } } } /// Render a section header for effect categories #[allow(dead_code)] // Part of List/Grid view rendering subsystem, not yet wired 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, dead_code)] 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.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)) } 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)) } } AudioClipType::Recording => { // Recording in progress - show placeholder 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 items (folders and assets) based on current view mode fn render_items( &mut self, ui: &mut egui::Ui, rect: egui::Rect, path: &NodePath, shared: &mut SharedPaneState, items: &[&LibraryItem], document: &Document, ) { match self.view_mode { AssetViewMode::List => { self.render_items_list_view(ui, rect, path, shared, items, document); } AssetViewMode::Grid => { self.render_items_grid_view(ui, rect, path, shared, items, document); } } } /// Render items in list view (folders + assets) fn render_items_list_view( &mut self, ui: &mut egui::Ui, rect: egui::Rect, _path: &NodePath, shared: &mut SharedPaneState, items: &[&LibraryItem], document: &Document, ) { // Load folder icon if needed let folder_icon = self.get_folder_icon(ui.ctx()).cloned(); let _scroll_area = egui::ScrollArea::vertical() .id_salt("asset_library_scroll") .show_viewport(ui, |ui, viewport| { ui.set_min_width(rect.width()); for item in items { match item { LibraryItem::Folder(folder) => { // Render folder item let item_rect = egui::Rect::from_min_size( egui::pos2(rect.min.x, ui.cursor().top()), egui::vec2(rect.width(), ITEM_HEIGHT), ); if viewport.intersects(item_rect) { let response = ui.allocate_rect(item_rect, egui::Sense::click()); // Check if an asset is being dragged and matches this folder's category let is_valid_drop_target = shared.dragging_asset.as_ref().map(|drag| { let drag_category = Self::drag_clip_type_to_category(drag.clip_type); drag_category == self.selected_category }).unwrap_or(false); let is_drop_hover = is_valid_drop_target && response.hovered(); // Background let bg_color = if is_drop_hover { // Highlight as drop target egui::Color32::from_rgb(60, 100, 140) } else if response.hovered() { egui::Color32::from_rgb(50, 50, 50) } else { egui::Color32::from_rgb(35, 35, 35) }; ui.painter().rect_filled(item_rect, 0.0, bg_color); // Draw drop target indicator border if is_drop_hover { ui.painter().rect_stroke( item_rect, 0.0, egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 180, 255)), egui::StrokeKind::Middle, ); } // Folder icon if let Some(ref icon) = folder_icon { let icon_size = LIST_THUMBNAIL_SIZE; let icon_rect = egui::Rect::from_min_size( item_rect.min + egui::vec2(4.0, (ITEM_HEIGHT - icon_size) / 2.0), egui::vec2(icon_size, icon_size), ); 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, ); } // Folder name (or inline edit field) let is_renaming = self.folder_rename_state.as_ref().map(|s| s.folder_id == folder.id).unwrap_or(false); if is_renaming { // Inline rename text field let name_rect = egui::Rect::from_min_size( item_rect.min + egui::vec2(LIST_THUMBNAIL_SIZE + 8.0, (ITEM_HEIGHT - 22.0) / 2.0), egui::vec2(200.0, 22.0), ); if let Some(ref mut state) = self.folder_rename_state { let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); ImeTextField::new(&mut state.edit_text) .font_size(13.0) .desired_width(name_rect.width()) .request_focus() .show(&mut child_ui); } } else { ui.painter().text( item_rect.min + egui::vec2(LIST_THUMBNAIL_SIZE + 12.0, ITEM_HEIGHT / 2.0), egui::Align2::LEFT_CENTER, &folder.name, egui::FontId::proportional(13.0), egui::Color32::WHITE, ); } // Item count let count_text = format!("{} items", folder.item_count); ui.painter().text( item_rect.max - egui::vec2(8.0, ITEM_HEIGHT / 2.0), egui::Align2::RIGHT_CENTER, count_text, egui::FontId::proportional(11.0), egui::Color32::from_rgb(150, 150, 150), ); // Handle drop: move asset to folder if is_drop_hover && ui.input(|i| i.pointer.any_released()) { if let Some(ref drag) = shared.dragging_asset.clone() { let core_category = Self::drag_clip_type_to_core_category(drag.clip_type); let action = lightningbeam_core::actions::MoveAssetToFolderAction::new( core_category, drag.clip_id, Some(folder.id), ); let _ = shared.action_executor.execute(Box::new(action)); *shared.dragging_asset = None; } } // Handle double-click to navigate into folder if response.double_clicked() { self.set_current_folder(Some(folder.id)); } // Handle right-click for context menu if response.secondary_clicked() { self.folder_context_menu = Some(FolderContextMenuState { folder_id: folder.id, position: ui.ctx().pointer_interact_pos().unwrap_or(egui::pos2(0.0, 0.0)), }); } } else { ui.allocate_space(egui::vec2(rect.width(), ITEM_HEIGHT)); } } LibraryItem::Asset(asset) => { // Render asset item let item_rect = egui::Rect::from_min_size( egui::pos2(rect.min.x, ui.cursor().top()), egui::vec2(rect.width(), ITEM_HEIGHT), ); if viewport.intersects(item_rect) { self.render_single_asset_list(ui, asset, item_rect, document, shared); } else { ui.allocate_space(egui::vec2(rect.width(), ITEM_HEIGHT)); } } } } }); } /// Render items in grid view (folders + assets) fn render_items_grid_view( &mut self, ui: &mut egui::Ui, rect: egui::Rect, path: &NodePath, shared: &mut SharedPaneState, items: &[&LibraryItem], document: &Document, ) { // Load folder icon if needed let folder_icon = self.get_folder_icon(ui.ctx()).cloned(); ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { egui::ScrollArea::vertical() .id_salt(("asset_library_grid_scroll", path)) .auto_shrink([false, false]) .show(ui, |ui| { ui.set_min_width(rect.width() - 16.0); // Account for scrollbar let items_per_row = ((rect.width() - GRID_SPACING) / (GRID_ITEM_SIZE + GRID_SPACING)).floor() as usize; let items_per_row = items_per_row.max(1); for row_start in (0..items.len()).step_by(items_per_row) { ui.horizontal(|ui| { for i in 0..items_per_row { let index = row_start + i; if index >= items.len() { break; } let item = items[index]; match item { LibraryItem::Folder(folder) => { // Render folder in grid (with space for name and count below) let (rect, response) = ui.allocate_exact_size( egui::vec2(GRID_ITEM_SIZE, GRID_ITEM_SIZE + 20.0), egui::Sense::click(), ); // Check if an asset is being dragged and matches this folder's category let is_valid_drop_target = shared.dragging_asset.as_ref().map(|drag| { let drag_category = Self::drag_clip_type_to_category(drag.clip_type); drag_category == self.selected_category }).unwrap_or(false); let is_drop_hover = is_valid_drop_target && response.hovered(); // Background let bg_color = if is_drop_hover { // Highlight as drop target egui::Color32::from_rgb(60, 100, 140) } else if response.hovered() { egui::Color32::from_rgb(50, 50, 50) } else { egui::Color32::from_rgb(35, 35, 35) }; ui.painter().rect_filled(rect, 4.0, bg_color); // Draw drop target indicator border if is_drop_hover { ui.painter().rect_stroke( rect, 4.0, egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 180, 255)), egui::StrokeKind::Middle, ); } // Folder icon (centered) if let Some(ref icon) = folder_icon { let icon_size = 48.0; let icon_rect = egui::Rect::from_center_size( rect.center() - egui::vec2(0.0, 8.0), egui::vec2(icon_size, icon_size), ); 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, ); } // Folder name (bottom, truncated) let name = if folder.name.len() > 12 { format!("{}...", &folder.name[..9]) } else { folder.name.clone() }; ui.painter().text( rect.center() + egui::vec2(0.0, 20.0), egui::Align2::CENTER_CENTER, name, egui::FontId::proportional(10.0), egui::Color32::WHITE, ); // Item count ui.painter().text( rect.center() + egui::vec2(0.0, 32.0), egui::Align2::CENTER_CENTER, format!("{} items", folder.item_count), egui::FontId::proportional(9.0), egui::Color32::from_rgb(150, 150, 150), ); // Handle drop: move asset to folder if is_drop_hover && ui.input(|i| i.pointer.any_released()) { if let Some(ref drag) = shared.dragging_asset.clone() { let core_category = Self::drag_clip_type_to_core_category(drag.clip_type); let action = lightningbeam_core::actions::MoveAssetToFolderAction::new( core_category, drag.clip_id, Some(folder.id), ); let _ = shared.action_executor.execute(Box::new(action)); *shared.dragging_asset = None; } } // Handle double-click to navigate into folder if response.double_clicked() { self.set_current_folder(Some(folder.id)); } // Handle right-click for context menu if response.secondary_clicked() { self.folder_context_menu = Some(FolderContextMenuState { folder_id: folder.id, position: ui.ctx().pointer_interact_pos().unwrap_or(egui::pos2(0.0, 0.0)), }); } } LibraryItem::Asset(asset) => { // Allocate rect for asset grid item (with space for name below) let (item_rect, _response) = ui.allocate_exact_size( egui::vec2(GRID_ITEM_SIZE, GRID_ITEM_SIZE + 20.0), egui::Sense::hover(), ); self.render_single_asset_grid(ui, asset, item_rect, document, shared); } } } }); ui.add_space(GRID_SPACING); } }); }); } /// Helper to render a single asset in list view fn render_single_asset_list( &mut self, ui: &mut egui::Ui, asset: &AssetEntry, item_rect: egui::Rect, document: &Document, shared: &mut SharedPaneState, ) -> egui::Response { let response = ui.allocate_rect(item_rect, 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); // Text colors let text_color = egui::Color32::from_gray(200); let secondary_text_color = egui::Color32::from_gray(120); // 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, 3.0, item_bg); // Category color indicator bar let indicator_color = asset.category.color(); let indicator_rect = egui::Rect::from_min_size( item_rect.min, egui::vec2(4.0, ITEM_HEIGHT), ); ui.painter().rect_filled(indicator_rect, 0.0, indicator_color); // Asset name ui.painter().text( item_rect.min + egui::vec2(12.0, 8.0), egui::Align2::LEFT_TOP, &asset.name, egui::FontId::proportional(13.0), text_color, ); // Metadata line let metadata = if asset.category == AssetCategory::Images { asset.extra_info.clone() } else if let Some((w, h)) = asset.dimensions { format!( "{:.1}s | {}x{} | {}", asset.duration, w as u32, h as u32, asset.extra_info ) } else { format!("{:.1}s | {}", asset.duration, asset.extra_info) }; ui.painter().text( item_rect.min + egui::vec2(12.0, 24.0), egui::Align2::LEFT_TOP, &metadata, egui::FontId::proportional(10.0), secondary_text_color, ); // Thumbnail on the right side let thumbnail_rect = egui::Rect::from_min_size( egui::pos2( item_rect.max.x - LIST_THUMBNAIL_SIZE - 4.0, item_rect.min.y + (ITEM_HEIGHT - LIST_THUMBNAIL_SIZE) / 2.0, ), egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE), ); // Generate and display thumbnail let asset_id = asset.id; let asset_category = asset.category; let ctx = ui.ctx().clone(); 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 { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); if let Some(ref peaks) = 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)) } } AudioClipType::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } } else { None } } AssetCategory::Effects => { if let Some(rgba) = shared.effect_thumbnail_cache.get(&asset_id) { Some(rgba.clone()) } else { shared.effect_thumbnail_requests.push(asset_id); None } } AssetCategory::All => None, } }); 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, ); } // Handle drag start if response.drag_started() { let linked_audio_clip_id = if asset_category == AssetCategory::Video { document.video_clips.get(&asset_id) .and_then(|clip| clip.linked_audio_clip_id) } else { None }; *shared.dragging_asset = Some(DraggingAsset { clip_type: asset.drag_clip_type, clip_id: asset_id, name: asset.name.clone(), duration: asset.duration, dimensions: asset.dimensions, linked_audio_clip_id, }); } // Handle right-click for context menu if response.secondary_clicked() { self.context_menu = Some(ContextMenuState { asset_id: asset.id, position: ui.ctx().pointer_interact_pos().unwrap_or(egui::pos2(0.0, 0.0)), }); } // Handle selection if response.clicked() { self.selected_asset = Some(asset.id); } response } /// Helper to render a single asset in grid view fn render_single_asset_grid( &mut self, ui: &mut egui::Ui, asset: &AssetEntry, rect: egui::Rect, document: &Document, shared: &mut SharedPaneState, ) -> egui::Response { let response = ui.interact(rect, egui::Id::new(("grid_asset", 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); // Background let bg_color = 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(50, 50, 50) } else { egui::Color32::from_rgb(35, 35, 35) }; ui.painter().rect_filled(rect, 4.0, bg_color); // Thumbnail let thumbnail_size = 64.0; let thumbnail_rect = egui::Rect::from_min_size( egui::pos2( rect.center().x - thumbnail_size / 2.0, rect.min.y + 8.0, ), egui::vec2(thumbnail_size, thumbnail_size), ); let asset_id = asset.id; let asset_category = asset.category; let ctx = ui.ctx().clone(); 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 { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); if let Some(ref peaks) = 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)) } } AudioClipType::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } } else { None } } AssetCategory::Effects => { if let Some(rgba) = shared.effect_thumbnail_cache.get(&asset_id) { Some(rgba.clone()) } else { shared.effect_thumbnail_requests.push(asset_id); None } } AssetCategory::All => None, } }); 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, ); } // Category 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, 3.0), ); ui.painter().rect_filled(indicator_rect, 0.0, asset.category.color()); // Asset name let name = if asset.name.len() > 12 { format!("{}...", &asset.name[..9]) } else { asset.name.clone() }; ui.painter().text( rect.center() + egui::vec2(0.0, 40.0), egui::Align2::CENTER_CENTER, name, egui::FontId::proportional(10.0), egui::Color32::WHITE, ); // Handle interactions if response.clicked() { self.selected_asset = Some(asset.id); } if response.secondary_clicked() { self.context_menu = Some(ContextMenuState { asset_id: asset.id, position: ui.ctx().pointer_interact_pos().unwrap_or(egui::pos2(0.0, 0.0)), }); } if response.drag_started() { let linked_audio_clip_id = if asset_category == AssetCategory::Video { document.video_clips.get(&asset_id) .and_then(|clip| clip.linked_audio_clip_id) } else { None }; *shared.dragging_asset = Some(DraggingAsset { clip_type: asset.drag_clip_type, clip_id: asset_id, name: asset.name.clone(), duration: asset.duration, dimensions: asset.dimensions, linked_audio_clip_id, }); } response } /// Render assets based on current view mode #[allow(dead_code)] fn render_assets( &mut self, ui: &mut egui::Ui, rect: egui::Rect, path: &NodePath, shared: &mut SharedPaneState, assets: &[&AssetEntry], document: &Document, ) { match self.view_mode { AssetViewMode::List => { self.render_asset_list_view(ui, rect, path, shared, assets, document); } AssetViewMode::Grid => { self.render_asset_grid_view(ui, rect, path, shared, assets, document); } } } /// Render the asset list view #[allow(dead_code)] fn render_asset_list_view( &mut self, ui: &mut egui::Ui, rect: egui::Rect, path: &NodePath, shared: &mut SharedPaneState, assets: &[&AssetEntry], document: &Document, ) { // Background let bg_style = shared.theme.style(".panel-content", ui.ctx()); let bg_color = bg_style .background_color .unwrap_or(egui::Color32::from_rgb(25, 25, 25)); ui.painter().rect_filled(rect, 0.0, bg_color); // Text colors let text_style = shared.theme.style(".text-primary", ui.ctx()); let text_color = text_style .text_color .unwrap_or(egui::Color32::from_gray(200)); let secondary_text_color = egui::Color32::from_gray(120); // Show empty state message if no assets if assets.is_empty() { let message = if !self.search_filter.is_empty() { "No assets match your search" } else { "No assets in this category" }; ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, message, egui::FontId::proportional(14.0), secondary_text_color, ); return; } // Use egui's built-in ScrollArea for scrolling let scroll_area_rect = rect; ui.scope_builder(egui::UiBuilder::new().max_rect(scroll_area_rect), |ui| { egui::ScrollArea::vertical() .id_salt(("asset_list_scroll", path)) .auto_shrink([false, false]) .show(ui, |ui| { ui.set_min_width(scroll_area_rect.width() - 16.0); // Account for scrollbar // 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(), ); 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) // Highlight when dragging } 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, 3.0, item_bg); // Category color indicator bar let indicator_color = asset.category.color(); let indicator_rect = egui::Rect::from_min_size( item_rect.min, egui::vec2(4.0, ITEM_HEIGHT), ); ui.painter().rect_filled(indicator_rect, 0.0, indicator_color); // Asset name (or inline edit field) let is_renaming = self.rename_state.as_ref().map(|s| s.asset_id == asset.id).unwrap_or(false); if is_renaming { // Inline rename text field using IME-safe widget let name_rect = egui::Rect::from_min_size( item_rect.min + egui::vec2(10.0, 4.0), egui::vec2(item_rect.width() - 20.0, 18.0), ); if let Some(ref mut state) = self.rename_state { let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); ImeTextField::new(&mut state.edit_text) .font_size(13.0) .desired_width(name_rect.width()) .request_focus() .show(&mut child_ui); } } else { // Normal asset name display ui.painter().text( item_rect.min + egui::vec2(12.0, 8.0), egui::Align2::LEFT_TOP, &asset.name, egui::FontId::proportional(13.0), text_color, ); } // Metadata line (images don't have duration) let metadata = if asset.category == AssetCategory::Images { // For images, just show dimensions asset.extra_info.clone() } else if let Some((w, h)) = asset.dimensions { format!( "{:.1}s | {}x{} | {}", asset.duration, w as u32, h as u32, asset.extra_info ) } else { format!("{:.1}s | {}", asset.duration, asset.extra_info) }; ui.painter().text( item_rect.min + egui::vec2(12.0, 24.0), egui::Align2::LEFT_TOP, &metadata, egui::FontId::proportional(10.0), secondary_text_color, ); // Thumbnail on the right side let thumbnail_rect = egui::Rect::from_min_size( egui::pos2( item_rect.max.x - LIST_THUMBNAIL_SIZE - 4.0, item_rect.min.y + (ITEM_HEIGHT - LIST_THUMBNAIL_SIZE) / 2.0, ), egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE), ); // 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 { let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); 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 => { // Check if it's sampled or MIDI 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)) } } AudioClipType::Recording => { 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 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) 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 if response.double_clicked() { // 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 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, }); } // Add small spacing between items ui.add_space(ITEM_PADDING); } }); }); // Draw drag preview at cursor when dragging if let Some(dragging) = shared.dragging_asset.as_ref() { if let Some(pos) = ui.ctx().pointer_interact_pos() { // Draw a semi-transparent preview let preview_rect = egui::Rect::from_min_size( pos + egui::vec2(10.0, 10.0), // Offset from cursor egui::vec2(150.0, 30.0), ); // Use top layer for drag preview let painter = ui.ctx().layer_painter(egui::LayerId::new( egui::Order::Tooltip, egui::Id::new("drag_preview"), )); painter.rect_filled( preview_rect, 4.0, egui::Color32::from_rgba_unmultiplied(60, 60, 60, 220), ); painter.text( preview_rect.center(), egui::Align2::CENTER_CENTER, &dragging.name, egui::FontId::proportional(12.0), egui::Color32::WHITE, ); } } // Clear drag state when mouse is released (if not dropped on valid target) // Note: Valid drop targets (Timeline, Stage) will clear this themselves if ui.input(|i| i.pointer.any_released()) { // Only clear if we're still within this pane (dropped back on library) if let Some(pos) = ui.ctx().pointer_interact_pos() { if rect.contains(pos) { *shared.dragging_asset = None; } } } } /// Render the asset grid view #[allow(dead_code)] fn render_asset_grid_view( &mut self, ui: &mut egui::Ui, rect: egui::Rect, path: &NodePath, shared: &mut SharedPaneState, assets: &[&AssetEntry], document: &Document, ) { // Background let bg_style = shared.theme.style(".panel-content", ui.ctx()); let bg_color = bg_style .background_color .unwrap_or(egui::Color32::from_rgb(25, 25, 25)); ui.painter().rect_filled(rect, 0.0, bg_color); // Text color let text_style = shared.theme.style(".text-primary", ui.ctx()); let text_color = text_style .text_color .unwrap_or(egui::Color32::from_gray(200)); let secondary_text_color = egui::Color32::from_gray(120); // Show empty state message if no assets if assets.is_empty() { let message = if !self.search_filter.is_empty() { "No assets match your search" } else { "No assets in this category" }; ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, message, egui::FontId::proportional(14.0), secondary_text_color, ); return; } // Calculate grid layout let content_width = rect.width() - 16.0; // Account for scrollbar let columns = ((content_width + GRID_SPACING) / (GRID_ITEM_SIZE + GRID_SPACING)) .floor() .max(1.0) as usize; let item_height = GRID_ITEM_SIZE + 20.0; // 20 for name below thumbnail // 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.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { egui::ScrollArea::vertical() .id_salt(("asset_grid_scroll", path)) .auto_shrink([false, false]) .show(ui, |ui| { ui.set_min_width(content_width); // Render built-in section header if show_effects_sections && builtin_count > 0 { Self::render_section_header(ui, "Built-in Effects", secondary_text_color); } // 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); } // 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); } // Render custom section header if show_effects_sections && custom_count > 0 { Self::render_section_header(ui, "Custom Effects", secondary_text_color); } // 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); } // 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); } }); }); // Draw drag preview at cursor when dragging if let Some(dragging) = shared.dragging_asset.as_ref() { if let Some(pos) = ui.ctx().pointer_interact_pos() { let preview_rect = egui::Rect::from_min_size( pos + egui::vec2(10.0, 10.0), egui::vec2(150.0, 30.0), ); let painter = ui.ctx().layer_painter(egui::LayerId::new( egui::Order::Tooltip, egui::Id::new("drag_preview"), )); painter.rect_filled( preview_rect, 4.0, egui::Color32::from_rgba_unmultiplied(60, 60, 60, 220), ); painter.text( preview_rect.center(), egui::Align2::CENTER_CENTER, &dragging.name, egui::FontId::proportional(12.0), egui::Color32::WHITE, ); } } // Clear drag state when mouse is released if ui.input(|i| i.pointer.any_released()) { if let Some(pos) = ui.ctx().pointer_interact_pos() { if rect.contains(pos) { *shared.dragging_asset = None; } } } } } impl PaneRenderer for AssetLibraryPane { fn render_content( &mut self, ui: &mut egui::Ui, rect: egui::Rect, path: &NodePath, shared: &mut SharedPaneState, ) { // Get an Arc clone of the document for thumbnail generation // This allows us to pass &mut shared to render functions while still accessing document let document_arc = shared.action_executor.document_arc(); // Invalidate thumbnails for audio clips that got new waveform data if !shared.audio_pools_with_new_waveforms.is_empty() { println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms); let mut invalidated_any = false; for (asset_id, clip) in &document_arc.audio_clips { if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) { println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index); self.thumbnail_cache.invalidate(asset_id); invalidated_any = true; } } } // Force a repaint if we invalidated any thumbnails if invalidated_any { println!("🔄 [ASSET_LIB] Requesting repaint after invalidating thumbnails"); ui.ctx().request_repaint(); } } // 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 items (folders and assets) let all_items = self.collect_items(&document_arc); // Filter items by search text let search_lower = self.search_filter.to_lowercase(); let filtered_items: Vec<&LibraryItem> = all_items .iter() .filter(|item| { if search_lower.is_empty() { true } else { item.name().to_lowercase().contains(&search_lower) } }) .collect(); // Layout: Search bar -> Category tabs -> Breadcrumbs -> Asset list let search_rect = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), SEARCH_BAR_HEIGHT)); let tabs_rect = egui::Rect::from_min_size( rect.min + egui::vec2(0.0, SEARCH_BAR_HEIGHT), egui::vec2(rect.width(), CATEGORY_TAB_HEIGHT), ); let breadcrumb_rect = egui::Rect::from_min_size( rect.min + egui::vec2(0.0, SEARCH_BAR_HEIGHT + CATEGORY_TAB_HEIGHT), egui::vec2(rect.width(), BREADCRUMB_HEIGHT), ); let list_rect = egui::Rect::from_min_max( rect.min + egui::vec2(0.0, SEARCH_BAR_HEIGHT + CATEGORY_TAB_HEIGHT + BREADCRUMB_HEIGHT), rect.max, ); // Render components self.render_search_bar(ui, search_rect, shared); self.render_category_tabs(ui, tabs_rect, shared); self.render_breadcrumbs(ui, breadcrumb_rect, &document_arc, shared); self.render_items(ui, list_rect, path, shared, &filtered_items, &document_arc); // Detect right-click on pane background (not on items) // Only allow folder creation in categories with folder support (not "All") // Don't trigger if we already opened a folder or asset context menu if self.selected_category != AssetCategory::All { if ui.input(|i| i.pointer.secondary_clicked()) { if self.folder_context_menu.is_none() && self.context_menu.is_none() { if let Some(pos) = ui.ctx().pointer_interact_pos() { if list_rect.contains(pos) { self.pane_context_menu = Some(pos); } } } } } // Context menu handling if let Some(ref context_state) = self.context_menu.clone() { let context_asset_id = context_state.asset_id; let menu_pos = context_state.position; // Find the asset info from all_items let asset_opt = all_items.iter().find_map(|item| { match item { LibraryItem::Asset(asset) if asset.id == context_asset_id => Some(asset), _ => None, } }); if let Some(asset) = asset_opt { let asset_name = asset.name.clone(); let asset_category = asset.category; let asset_is_builtin = asset.is_builtin; let asset_folder_id = asset.folder_id; let in_use = Self::is_asset_in_use( shared.action_executor.document(), context_asset_id, asset_category, ); // Get folders for this category (for Move to Folder submenu) let folders: Vec<(Uuid, String)> = if let Some(core_cat) = Self::to_core_category(asset_category) { let tree = document_arc.get_folder_tree(core_cat); tree.folders.iter() .map(|(id, f)| (*id, f.name.clone())) .collect() } else { Vec::new() }; // Show context menu popup at the stored position let menu_id = egui::Id::new("asset_context_menu"); let menu_response = egui::Area::new(menu_id) .order(egui::Order::Foreground) .fixed_pos(menu_pos) .show(ui.ctx(), |ui| { 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") .color(egui::Color32::from_gray(120)) .italics()); } else { if ui.button("Rename").clicked() { // Start inline rename self.rename_state = Some(RenameState { asset_id: context_asset_id, category: asset_category, edit_text: asset_name.clone(), }); self.context_menu = None; } if ui.button("Delete").clicked() { // Set up pending delete confirmation self.pending_delete = Some(PendingDelete { asset_id: context_asset_id, asset_name: asset_name.clone(), category: asset_category, in_use, }); self.context_menu = None; } // Move to Folder submenu (only show if there are folders or asset is not at root) if !folders.is_empty() || asset_folder_id.is_some() { ui.separator(); ui.menu_button("Move to Folder", |ui| { // Move to Root option (if not already at root) if asset_folder_id.is_some() { if ui.button("Root").clicked() { if let Some(core_cat) = Self::to_core_category(asset_category) { let action = lightningbeam_core::actions::MoveAssetToFolderAction::new( core_cat, context_asset_id, None, ); let _ = shared.action_executor.execute(Box::new(action)); } self.context_menu = None; } if !folders.is_empty() { ui.separator(); } } // List all folders (except current folder) for (folder_id, folder_name) in &folders { if asset_folder_id != Some(*folder_id) { if ui.button(folder_name).clicked() { if let Some(core_cat) = Self::to_core_category(asset_category) { let action = lightningbeam_core::actions::MoveAssetToFolderAction::new( core_cat, context_asset_id, Some(*folder_id), ); let _ = shared.action_executor.execute(Box::new(action)); } self.context_menu = None; } } } }); } } }); }); // Close menu on click outside (using primary button release) let menu_rect = menu_response.response.rect; if ui.input(|i| i.pointer.primary_released()) { if let Some(pos) = ui.ctx().pointer_interact_pos() { if !menu_rect.contains(pos) { self.context_menu = None; } } } // Also close on Escape if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.context_menu = None; } } else { self.context_menu = None; } } // Pane context menu (for creating folders) if let Some(menu_pos) = self.pane_context_menu { let menu_id = egui::Id::new("pane_context_menu"); let menu_response = egui::Area::new(menu_id) .order(egui::Order::Foreground) .fixed_pos(menu_pos) .show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(150.0); if ui.button("New Folder").clicked() { // Get the current folder for this category let parent_folder_id = self.get_current_folder(); // Get the core category if let Some(core_category) = Self::to_core_category(self.selected_category) { // Create folder action let action = lightningbeam_core::actions::CreateFolderAction::new( core_category, "New Folder", parent_folder_id, ); if shared.action_executor.execute(Box::new(action)).is_ok() { // Successfully created folder } } self.pane_context_menu = None; } }) }); // Close menu on click outside (using primary button release to avoid first-frame issue) let menu_rect = menu_response.response.rect; if ui.input(|i| i.pointer.primary_released()) { if let Some(pos) = ui.ctx().pointer_interact_pos() { if !menu_rect.contains(pos) { self.pane_context_menu = None; } } } // Also close on Escape if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.pane_context_menu = None; } } // Folder context menu (for rename/delete/etc) if let Some(ref folder_state) = self.folder_context_menu.clone() { let folder_id = folder_state.folder_id; let menu_pos = folder_state.position; // Get the folder from the document if let Some(core_category) = Self::to_core_category(self.selected_category) { let folder_tree = document_arc.get_folder_tree(core_category); if let Some(folder) = folder_tree.folders.get(&folder_id) { let folder_name = folder.name.clone(); let menu_id = egui::Id::new("folder_context_menu"); let menu_response = egui::Area::new(menu_id) .order(egui::Order::Foreground) .fixed_pos(menu_pos) .show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(150.0); if ui.button("Rename").clicked() { // Enter rename mode for folder self.folder_rename_state = Some(FolderRenameState { folder_id, category: self.selected_category, edit_text: folder_name.clone(), }); self.folder_context_menu = None; } if ui.button("Delete").clicked() { // Execute delete folder action let action = lightningbeam_core::actions::DeleteFolderAction::new( core_category, folder_id, lightningbeam_core::actions::DeleteStrategy::MoveToParent, ); let _ = shared.action_executor.execute(Box::new(action)); self.folder_context_menu = None; } }) }); // Close menu on click outside (using primary button release to avoid first-frame issue) let menu_rect = menu_response.response.rect; if ui.input(|i| i.pointer.primary_released()) { if let Some(pos) = ui.ctx().pointer_interact_pos() { if !menu_rect.contains(pos) { self.folder_context_menu = None; } } } // Also close on Escape if ui.input(|i| i.key_pressed(egui::Key::Escape)) { self.folder_context_menu = None; } } else { self.folder_context_menu = None; } } else { self.folder_context_menu = None; } } // Draw drag preview at cursor when dragging an asset if let Some(dragging) = shared.dragging_asset.as_ref() { if let Some(pos) = ui.ctx().pointer_interact_pos() { // Draw a semi-transparent preview near the cursor let preview_rect = egui::Rect::from_min_size( pos + egui::vec2(12.0, 12.0), // Offset from cursor egui::vec2(160.0, 32.0), ); // Use top layer for drag preview so it appears above everything let painter = ui.ctx().layer_painter(egui::LayerId::new( egui::Order::Tooltip, egui::Id::new("asset_drag_preview"), )); // Background with rounded corners painter.rect_filled( preview_rect, 4.0, egui::Color32::from_rgba_unmultiplied(50, 80, 120, 230), ); // Border painter.rect_stroke( preview_rect, 4.0, egui::Stroke::new(1.0, egui::Color32::from_rgb(100, 160, 220)), egui::StrokeKind::Inside, ); // Asset name painter.text( preview_rect.center(), egui::Align2::CENTER_CENTER, &dragging.name, egui::FontId::proportional(12.0), egui::Color32::WHITE, ); } } // Clear drag state when mouse is released within the asset library // (dropped back on library without hitting a valid folder target) if ui.input(|i| i.pointer.any_released()) { if shared.dragging_asset.is_some() { if let Some(pos) = ui.ctx().pointer_interact_pos() { if rect.contains(pos) { *shared.dragging_asset = None; } } } } // Delete confirmation dialog if let Some(ref pending) = self.pending_delete.clone() { let window_id = egui::Id::new("delete_confirm_dialog"); let mut should_close = false; let mut should_delete = false; egui::Window::new("Confirm Delete") .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); if pending.in_use { ui.label(egui::RichText::new("Warning: This asset is currently in use!") .color(egui::Color32::from_rgb(255, 180, 100))); ui.add_space(4.0); ui.label("Deleting it will remove all clip instances that reference it."); ui.add_space(8.0); } ui.label(format!("Are you sure you want to delete \"{}\"?", pending.asset_name)); ui.add_space(12.0); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { should_close = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let delete_text = if pending.in_use { "Delete Anyway" } else { "Delete" }; if ui.button(delete_text).clicked() { should_delete = true; should_close = true; } }); }); }); if should_delete { // Perform the delete Self::delete_asset( shared.action_executor.document_mut(), pending.asset_id, pending.category, ); } if should_close { self.pending_delete = None; } } // Handle rename state (Enter to confirm, Escape to cancel, click outside to confirm) if let Some(ref state) = self.rename_state.clone() { let mut should_confirm = false; let mut should_cancel = false; // Check for Enter or Escape ui.input(|i| { if i.key_pressed(egui::Key::Enter) { should_confirm = true; } else if i.key_pressed(egui::Key::Escape) { should_cancel = true; } }); if should_confirm { let new_name = state.edit_text.trim(); if !new_name.is_empty() { Self::rename_asset( shared.action_executor.document_mut(), state.asset_id, state.category, new_name, ); } self.rename_state = None; } else if should_cancel { self.rename_state = None; } } // Handle folder rename state (Enter to confirm, Escape to cancel) if let Some(ref state) = self.folder_rename_state.clone() { let mut should_confirm = false; let mut should_cancel = false; // Check for Enter or Escape ui.input(|i| { if i.key_pressed(egui::Key::Enter) { should_confirm = true; } else if i.key_pressed(egui::Key::Escape) { should_cancel = true; } }); if should_confirm { let new_name = state.edit_text.trim(); if !new_name.is_empty() { // Execute rename folder action if let Some(core_category) = Self::to_core_category(state.category) { let action = lightningbeam_core::actions::RenameFolderAction::new( core_category, state.folder_id, new_name.to_string(), ); let _ = shared.action_executor.execute(Box::new(action)); } } self.folder_rename_state = None; } else if should_cancel { self.folder_rename_state = None; } } } fn name(&self) -> &str { "Asset Library" } }