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