Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs

3792 lines
157 KiB
Rust

//! 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<Vec<f32>>, 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<Uuid, egui::TextureHandle>,
/// Track which assets need regeneration
dirty: HashSet<Uuid>,
}
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<F>(
&mut self,
ctx: &egui::Context,
asset_id: Uuid,
generator: F,
) -> Option<&egui::TextureHandle>
where
F: FnOnce() -> Option<Vec<u8>>,
{
// 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<Vec<u8>> {
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<u8> {
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<u8> {
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<std::sync::Mutex<lightningbeam_core::video::VideoManager>>,
) -> Option<Vec<u8>> {
// 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<u8> {
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<u8> {
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 instance
for shape_instance in &vector_layer.shape_instances {
if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) {
// 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<u8> {
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<Uuid>,
}
/// 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<Uuid>,
/// Context menu state with position (for assets)
context_menu: Option<ContextMenuState>,
/// Folder context menu state (for folders)
folder_context_menu: Option<FolderContextMenuState>,
/// Pane context menu position (for background right-click)
pane_context_menu: Option<egui::Pos2>,
/// Pending delete confirmation
pending_delete: Option<PendingDelete>,
/// Active rename state (for assets)
rename_state: Option<RenameState>,
/// Active folder rename state
folder_rename_state: Option<FolderRenameState>,
/// 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<u8, Option<Uuid>>,
/// Set of expanded folder IDs (for tree view - future enhancement)
#[allow(dead_code)]
expanded_folders: HashSet<Uuid>,
/// Cached folder icon texture
folder_icon: Option<egui::TextureHandle>,
}
// 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<Uuid> {
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<Uuid>) {
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<lightningbeam_core::document::AssetCategory> {
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<AssetEntry> {
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<uuid::Uuid> = 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<Uuid> = 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<LibraryItem> {
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<Uuid> = 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 for clip instances referencing this asset
for layer in &document.root.children {
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<Vec<(f32, f32)>> =
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<Vec<(f32, f32)>> = 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<Vec<(f32, f32)>> = 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<Vec<(f32, f32)>> =
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<Vec<(f32, f32)>> = 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"
}
}