diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs index c780f60..52b3af2 100644 --- a/daw-backend/src/audio/pool.rs +++ b/daw-backend/src/audio/pool.rs @@ -82,25 +82,48 @@ impl AudioFile { /// Generate a waveform overview with the specified number of peaks /// This creates a downsampled representation suitable for timeline visualization pub fn generate_waveform_overview(&self, target_peaks: usize) -> Vec { + self.generate_waveform_overview_range(0, self.frames as usize, target_peaks) + } + + /// Generate a waveform overview for a specific range of frames + /// + /// # Arguments + /// * `start_frame` - Starting frame index (0-based) + /// * `end_frame` - Ending frame index (exclusive) + /// * `target_peaks` - Desired number of peaks to generate + pub fn generate_waveform_overview_range( + &self, + start_frame: usize, + end_frame: usize, + target_peaks: usize, + ) -> Vec { if self.frames == 0 || target_peaks == 0 { return Vec::new(); } let total_frames = self.frames as usize; - let frames_per_peak = (total_frames / target_peaks).max(1); - let actual_peaks = (total_frames + frames_per_peak - 1) / frames_per_peak; + let start_frame = start_frame.min(total_frames); + let end_frame = end_frame.min(total_frames); + + if start_frame >= end_frame { + return Vec::new(); + } + + let range_frames = end_frame - start_frame; + let frames_per_peak = (range_frames / target_peaks).max(1); + let actual_peaks = (range_frames + frames_per_peak - 1) / frames_per_peak; let mut peaks = Vec::with_capacity(actual_peaks); for peak_idx in 0..actual_peaks { - let start_frame = peak_idx * frames_per_peak; - let end_frame = ((peak_idx + 1) * frames_per_peak).min(total_frames); + let peak_start = start_frame + peak_idx * frames_per_peak; + let peak_end = (start_frame + (peak_idx + 1) * frames_per_peak).min(end_frame); let mut min = 0.0f32; let mut max = 0.0f32; // Scan all samples in this window - for frame_idx in start_frame..end_frame { + for frame_idx in peak_start..peak_end { // For multi-channel audio, combine all channels for ch in 0..self.channels as usize { let sample_idx = frame_idx * self.channels as usize + ch; @@ -159,6 +182,25 @@ impl AudioClipPool { }) } + /// Generate waveform overview for a specific range of a file in the pool + /// + /// # Arguments + /// * `pool_index` - Index of the file in the pool + /// * `start_frame` - Starting frame index (0-based) + /// * `end_frame` - Ending frame index (exclusive) + /// * `target_peaks` - Desired number of peaks to generate + pub fn generate_waveform_range( + &self, + pool_index: usize, + start_frame: usize, + end_frame: usize, + target_peaks: usize, + ) -> Option> { + self.files.get(pool_index).map(|file| { + file.generate_waveform_overview_range(start_frame, end_frame, target_peaks) + }) + } + /// Add an audio file to the pool and return its index pub fn add_file(&mut self, file: AudioFile) -> usize { let index = self.files.len(); diff --git a/daw-backend/src/io/audio_file.rs b/daw-backend/src/io/audio_file.rs index c704f92..1bd7031 100644 --- a/daw-backend/src/io/audio_file.rs +++ b/daw-backend/src/io/audio_file.rs @@ -136,25 +136,48 @@ impl AudioFile { /// Generate a waveform overview with the specified number of peaks /// This creates a downsampled representation suitable for timeline visualization pub fn generate_waveform_overview(&self, target_peaks: usize) -> Vec { + self.generate_waveform_overview_range(0, self.frames as usize, target_peaks) + } + + /// Generate a waveform overview for a specific range of frames + /// + /// # Arguments + /// * `start_frame` - Starting frame index (0-based) + /// * `end_frame` - Ending frame index (exclusive) + /// * `target_peaks` - Desired number of peaks to generate + pub fn generate_waveform_overview_range( + &self, + start_frame: usize, + end_frame: usize, + target_peaks: usize, + ) -> Vec { if self.frames == 0 || target_peaks == 0 { return Vec::new(); } let total_frames = self.frames as usize; - let frames_per_peak = (total_frames / target_peaks).max(1); - let actual_peaks = (total_frames + frames_per_peak - 1) / frames_per_peak; + let start_frame = start_frame.min(total_frames); + let end_frame = end_frame.min(total_frames); + + if start_frame >= end_frame { + return Vec::new(); + } + + let range_frames = end_frame - start_frame; + let frames_per_peak = (range_frames / target_peaks).max(1); + let actual_peaks = (range_frames + frames_per_peak - 1) / frames_per_peak; let mut peaks = Vec::with_capacity(actual_peaks); for peak_idx in 0..actual_peaks { - let start_frame = peak_idx * frames_per_peak; - let end_frame = ((peak_idx + 1) * frames_per_peak).min(total_frames); + let peak_start = start_frame + peak_idx * frames_per_peak; + let peak_end = (start_frame + (peak_idx + 1) * frames_per_peak).min(end_frame); let mut min = 0.0f32; let mut max = 0.0f32; // Scan all samples in this window - for frame_idx in start_frame..end_frame { + for frame_idx in peak_start..peak_end { // For multi-channel audio, combine all channels for ch in 0..self.channels as usize { let sample_idx = frame_idx * self.channels as usize + ch; diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index b317adc..fd1bcaa 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -9,8 +9,16 @@ //! - `ActionExecutor`: Wraps the document and manages undo/redo stacks //! - Document mutations are only accessible via `pub(crate)` methods //! - External code gets read-only access via `ActionExecutor::document()` +//! +//! ## Memory Model +//! +//! The document is stored in an `Arc` for efficient cloning during +//! GPU render callbacks. When mutation is needed, `Arc::make_mut()` provides +//! copy-on-write semantics - if other Arc holders exist (e.g., in-flight render +//! callbacks), the document is cloned before mutation, preserving their snapshot. use crate::document::Document; +use std::sync::Arc; /// Action trait for undo/redo operations /// @@ -31,9 +39,12 @@ pub trait Action: Send { /// /// This is the only way to get mutable access to the document, ensuring /// all mutations go through the action system. +/// +/// The document is stored in `Arc` for efficient sharing with +/// render callbacks. Use `document_arc()` for cheap cloning to GPU passes. pub struct ActionExecutor { - /// The document being edited - document: Document, + /// The document being edited (wrapped in Arc for cheap cloning) + document: Arc, /// Stack of executed actions (for undo) undo_stack: Vec>, @@ -49,7 +60,7 @@ impl ActionExecutor { /// Create a new action executor with the given document pub fn new(document: Document) -> Self { Self { - document, + document: Arc::new(document), undo_stack: Vec::new(), redo_stack: Vec::new(), max_undo_depth: 100, // Default: keep last 100 actions @@ -64,19 +75,33 @@ impl ActionExecutor { &self.document } + /// Get a cheap clone of the document Arc for render callbacks + /// + /// Use this when passing the document to GPU render passes or other + /// contexts that need to own a reference. Cloning Arc is just a pointer + /// copy + atomic increment, not a deep clone. + pub fn document_arc(&self) -> Arc { + Arc::clone(&self.document) + } + /// Get mutable access to the document + /// + /// Uses copy-on-write semantics: if other Arc holders exist (e.g., in-flight + /// render callbacks), the document is cloned before mutation. Otherwise, + /// returns direct mutable access. + /// /// Note: This should only be used for live previews. Permanent changes /// should go through the execute() method to support undo/redo. pub fn document_mut(&mut self) -> &mut Document { - &mut self.document + Arc::make_mut(&mut self.document) } /// Execute an action and add it to the undo stack /// /// This clears the redo stack since we're creating a new timeline branch. pub fn execute(&mut self, mut action: Box) { - // Apply the action - action.execute(&mut self.document); + // Apply the action (uses copy-on-write if other Arc holders exist) + action.execute(Arc::make_mut(&mut self.document)); // Clear redo stack (new action invalidates redo history) self.redo_stack.clear(); @@ -95,8 +120,8 @@ impl ActionExecutor { /// Returns true if an action was undone, false if undo stack is empty. pub fn undo(&mut self) -> bool { if let Some(mut action) = self.undo_stack.pop() { - // Rollback the action - action.rollback(&mut self.document); + // Rollback the action (uses copy-on-write if other Arc holders exist) + action.rollback(Arc::make_mut(&mut self.document)); // Move to redo stack self.redo_stack.push(action); @@ -112,8 +137,8 @@ impl ActionExecutor { /// Returns true if an action was redone, false if redo stack is empty. pub fn redo(&mut self) -> bool { if let Some(mut action) = self.redo_stack.pop() { - // Re-execute the action - action.execute(&mut self.document); + // Re-execute the action (uses copy-on-write if other Arc holders exist) + action.execute(Arc::make_mut(&mut self.document)); // Move back to undo stack self.undo_stack.push(action); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index efe4d72..5dccc31 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -9,7 +9,10 @@ pub mod add_shape; pub mod move_clip_instances; pub mod move_objects; pub mod paint_bucket; +pub mod set_document_properties; +pub mod set_instance_properties; pub mod set_layer_properties; +pub mod set_shape_properties; pub mod transform_clip_instances; pub mod transform_objects; pub mod trim_clip_instances; @@ -20,7 +23,10 @@ pub use add_shape::AddShapeAction; pub use move_clip_instances::MoveClipInstancesAction; pub use move_objects::MoveShapeInstancesAction; pub use paint_bucket::PaintBucketAction; +pub use set_document_properties::SetDocumentPropertiesAction; +pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction}; pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction}; +pub use set_shape_properties::{SetShapePropertiesAction, ShapePropertyChange}; pub use transform_clip_instances::TransformClipInstancesAction; pub use transform_objects::TransformShapeInstancesAction; pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType}; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs new file mode 100644 index 0000000..c073df3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs @@ -0,0 +1,183 @@ +//! Set document properties action +//! +//! Handles changing document-level properties (width, height, duration, framerate) +//! with undo/redo support. + +use crate::action::Action; +use crate::document::Document; + +/// Individual property change for a document +#[derive(Clone, Debug)] +pub enum DocumentPropertyChange { + Width(f64), + Height(f64), + Duration(f64), + Framerate(f64), +} + +impl DocumentPropertyChange { + /// Extract the f64 value from any variant + fn value(&self) -> f64 { + match self { + DocumentPropertyChange::Width(v) => *v, + DocumentPropertyChange::Height(v) => *v, + DocumentPropertyChange::Duration(v) => *v, + DocumentPropertyChange::Framerate(v) => *v, + } + } +} + +/// Action that sets a property on the document +pub struct SetDocumentPropertiesAction { + /// The new property value + property: DocumentPropertyChange, + /// The old value for undo + old_value: Option, +} + +impl SetDocumentPropertiesAction { + /// Create a new action to set width + pub fn set_width(width: f64) -> Self { + Self { + property: DocumentPropertyChange::Width(width), + old_value: None, + } + } + + /// Create a new action to set height + pub fn set_height(height: f64) -> Self { + Self { + property: DocumentPropertyChange::Height(height), + old_value: None, + } + } + + /// Create a new action to set duration + pub fn set_duration(duration: f64) -> Self { + Self { + property: DocumentPropertyChange::Duration(duration), + old_value: None, + } + } + + /// Create a new action to set framerate + pub fn set_framerate(framerate: f64) -> Self { + Self { + property: DocumentPropertyChange::Framerate(framerate), + old_value: None, + } + } + + fn get_current_value(&self, document: &Document) -> f64 { + match &self.property { + DocumentPropertyChange::Width(_) => document.width, + DocumentPropertyChange::Height(_) => document.height, + DocumentPropertyChange::Duration(_) => document.duration, + DocumentPropertyChange::Framerate(_) => document.framerate, + } + } + + fn apply_value(&self, document: &mut Document, value: f64) { + match &self.property { + DocumentPropertyChange::Width(_) => document.width = value, + DocumentPropertyChange::Height(_) => document.height = value, + DocumentPropertyChange::Duration(_) => document.duration = value, + DocumentPropertyChange::Framerate(_) => document.framerate = value, + } + } +} + +impl Action for SetDocumentPropertiesAction { + fn execute(&mut self, document: &mut Document) { + // Store old value if not already stored + if self.old_value.is_none() { + self.old_value = Some(self.get_current_value(document)); + } + + // Apply new value + let new_value = self.property.value(); + self.apply_value(document, new_value); + } + + fn rollback(&mut self, document: &mut Document) { + if let Some(old_value) = self.old_value { + self.apply_value(document, old_value); + } + } + + fn description(&self) -> String { + let property_name = match &self.property { + DocumentPropertyChange::Width(_) => "canvas width", + DocumentPropertyChange::Height(_) => "canvas height", + DocumentPropertyChange::Duration(_) => "duration", + DocumentPropertyChange::Framerate(_) => "framerate", + }; + format!("Set {}", property_name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_set_width() { + let mut document = Document::new("Test"); + document.width = 1920.0; + + let mut action = SetDocumentPropertiesAction::set_width(1280.0); + action.execute(&mut document); + assert_eq!(document.width, 1280.0); + + action.rollback(&mut document); + assert_eq!(document.width, 1920.0); + } + + #[test] + fn test_set_height() { + let mut document = Document::new("Test"); + document.height = 1080.0; + + let mut action = SetDocumentPropertiesAction::set_height(720.0); + action.execute(&mut document); + assert_eq!(document.height, 720.0); + + action.rollback(&mut document); + assert_eq!(document.height, 1080.0); + } + + #[test] + fn test_set_duration() { + let mut document = Document::new("Test"); + document.duration = 10.0; + + let mut action = SetDocumentPropertiesAction::set_duration(30.0); + action.execute(&mut document); + assert_eq!(document.duration, 30.0); + + action.rollback(&mut document); + assert_eq!(document.duration, 10.0); + } + + #[test] + fn test_set_framerate() { + let mut document = Document::new("Test"); + document.framerate = 30.0; + + let mut action = SetDocumentPropertiesAction::set_framerate(60.0); + action.execute(&mut document); + assert_eq!(document.framerate, 60.0); + + action.rollback(&mut document); + assert_eq!(document.framerate, 30.0); + } + + #[test] + fn test_description() { + let action = SetDocumentPropertiesAction::set_width(100.0); + assert_eq!(action.description(), "Set canvas width"); + + let action = SetDocumentPropertiesAction::set_duration(30.0); + assert_eq!(action.description(), "Set duration"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_instance_properties.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_instance_properties.rs new file mode 100644 index 0000000..49cf7ed --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_instance_properties.rs @@ -0,0 +1,361 @@ +//! Set shape instance properties action +//! +//! Handles changing individual properties on shape instances (position, rotation, scale, etc.) +//! with undo/redo support. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// Individual property change for a shape instance +#[derive(Clone, Debug)] +pub enum InstancePropertyChange { + X(f64), + Y(f64), + Rotation(f64), + ScaleX(f64), + ScaleY(f64), + SkewX(f64), + SkewY(f64), + Opacity(f64), +} + +impl InstancePropertyChange { + /// Extract the f64 value from any variant + fn value(&self) -> f64 { + match self { + InstancePropertyChange::X(v) => *v, + InstancePropertyChange::Y(v) => *v, + InstancePropertyChange::Rotation(v) => *v, + InstancePropertyChange::ScaleX(v) => *v, + InstancePropertyChange::ScaleY(v) => *v, + InstancePropertyChange::SkewX(v) => *v, + InstancePropertyChange::SkewY(v) => *v, + InstancePropertyChange::Opacity(v) => *v, + } + } +} + +/// Action that sets a property on one or more shape instances +pub struct SetInstancePropertiesAction { + /// Layer containing the instances + layer_id: Uuid, + + /// Instance IDs to modify and their old values + instance_changes: Vec<(Uuid, Option)>, + + /// Property to change + property: InstancePropertyChange, +} + +impl SetInstancePropertiesAction { + /// Create a new action to set a property on a single instance + pub fn new(layer_id: Uuid, instance_id: Uuid, property: InstancePropertyChange) -> Self { + Self { + layer_id, + instance_changes: vec![(instance_id, None)], + property, + } + } + + /// Create a new action to set a property on multiple instances + pub fn new_batch(layer_id: Uuid, instance_ids: Vec, property: InstancePropertyChange) -> Self { + Self { + layer_id, + instance_changes: instance_ids.into_iter().map(|id| (id, None)).collect(), + property, + } + } + + fn get_instance_value(&self, document: &Document, instance_id: &Uuid) -> Option { + if let Some(layer) = document.get_layer(&self.layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + if let Some(instance) = vector_layer.get_object(instance_id) { + return Some(match &self.property { + InstancePropertyChange::X(_) => instance.transform.x, + InstancePropertyChange::Y(_) => instance.transform.y, + InstancePropertyChange::Rotation(_) => instance.transform.rotation, + InstancePropertyChange::ScaleX(_) => instance.transform.scale_x, + InstancePropertyChange::ScaleY(_) => instance.transform.scale_y, + InstancePropertyChange::SkewX(_) => instance.transform.skew_x, + InstancePropertyChange::SkewY(_) => instance.transform.skew_y, + InstancePropertyChange::Opacity(_) => instance.opacity, + }); + } + } + } + None + } + + fn apply_to_instance(&self, document: &mut Document, instance_id: &Uuid, value: f64) { + if let Some(layer) = document.get_layer_mut(&self.layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + vector_layer.modify_object_internal(instance_id, |instance| { + match &self.property { + InstancePropertyChange::X(_) => instance.transform.x = value, + InstancePropertyChange::Y(_) => instance.transform.y = value, + InstancePropertyChange::Rotation(_) => instance.transform.rotation = value, + InstancePropertyChange::ScaleX(_) => instance.transform.scale_x = value, + InstancePropertyChange::ScaleY(_) => instance.transform.scale_y = value, + InstancePropertyChange::SkewX(_) => instance.transform.skew_x = value, + InstancePropertyChange::SkewY(_) => instance.transform.skew_y = value, + InstancePropertyChange::Opacity(_) => instance.opacity = value, + } + }); + } + } + } +} + +impl Action for SetInstancePropertiesAction { + fn execute(&mut self, document: &mut Document) { + let new_value = self.property.value(); + let layer_id = self.layer_id; + + // First pass: collect old values for instances that don't have them yet + for (instance_id, old_value) in &mut self.instance_changes { + if old_value.is_none() { + // Get old value inline to avoid borrow issues + if let Some(layer) = document.get_layer(&layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + if let Some(instance) = vector_layer.get_object(instance_id) { + *old_value = Some(match &self.property { + InstancePropertyChange::X(_) => instance.transform.x, + InstancePropertyChange::Y(_) => instance.transform.y, + InstancePropertyChange::Rotation(_) => instance.transform.rotation, + InstancePropertyChange::ScaleX(_) => instance.transform.scale_x, + InstancePropertyChange::ScaleY(_) => instance.transform.scale_y, + InstancePropertyChange::SkewX(_) => instance.transform.skew_x, + InstancePropertyChange::SkewY(_) => instance.transform.skew_y, + InstancePropertyChange::Opacity(_) => instance.opacity, + }); + } + } + } + } + } + + // Second pass: apply new values + for (instance_id, _) in &self.instance_changes { + self.apply_to_instance(document, instance_id, new_value); + } + } + + fn rollback(&mut self, document: &mut Document) { + for (instance_id, old_value) in &self.instance_changes { + if let Some(value) = old_value { + self.apply_to_instance(document, instance_id, *value); + } + } + } + + fn description(&self) -> String { + let property_name = match &self.property { + InstancePropertyChange::X(_) => "X position", + InstancePropertyChange::Y(_) => "Y position", + InstancePropertyChange::Rotation(_) => "rotation", + InstancePropertyChange::ScaleX(_) => "scale X", + InstancePropertyChange::ScaleY(_) => "scale Y", + InstancePropertyChange::SkewX(_) => "skew X", + InstancePropertyChange::SkewY(_) => "skew Y", + InstancePropertyChange::Opacity(_) => "opacity", + }; + + if self.instance_changes.len() == 1 { + format!("Set {}", property_name) + } else { + format!("Set {} on {} objects", property_name, self.instance_changes.len()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::VectorLayer; + use crate::object::{ShapeInstance, Transform}; + + #[test] + fn test_set_x_position() { + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Test Layer"); + + let shape_id = Uuid::new_v4(); + let mut instance = ShapeInstance::new(shape_id); + let instance_id = instance.id; + instance.transform = Transform::with_position(10.0, 20.0); + layer.add_object(instance); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + // Create and execute action + let mut action = SetInstancePropertiesAction::new( + layer_id, + instance_id, + InstancePropertyChange::X(50.0), + ); + action.execute(&mut document); + + // Verify position changed + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let obj = vl.get_object(&instance_id).unwrap(); + assert_eq!(obj.transform.x, 50.0); + assert_eq!(obj.transform.y, 20.0); // Y unchanged + } + + // Rollback + action.rollback(&mut document); + + // Verify restored + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let obj = vl.get_object(&instance_id).unwrap(); + assert_eq!(obj.transform.x, 10.0); + } + } + + #[test] + fn test_set_rotation() { + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Test Layer"); + + let shape_id = Uuid::new_v4(); + let mut instance = ShapeInstance::new(shape_id); + let instance_id = instance.id; + instance.transform.rotation = 0.0; + layer.add_object(instance); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + // Create and execute action + let mut action = SetInstancePropertiesAction::new( + layer_id, + instance_id, + InstancePropertyChange::Rotation(45.0), + ); + action.execute(&mut document); + + // Verify rotation changed + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let obj = vl.get_object(&instance_id).unwrap(); + assert_eq!(obj.transform.rotation, 45.0); + } + + // Rollback + action.rollback(&mut document); + + // Verify restored + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let obj = vl.get_object(&instance_id).unwrap(); + assert_eq!(obj.transform.rotation, 0.0); + } + } + + #[test] + fn test_set_opacity() { + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Test Layer"); + + let shape_id = Uuid::new_v4(); + let mut instance = ShapeInstance::new(shape_id); + let instance_id = instance.id; + instance.opacity = 1.0; + layer.add_object(instance); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + // Create and execute action + let mut action = SetInstancePropertiesAction::new( + layer_id, + instance_id, + InstancePropertyChange::Opacity(0.5), + ); + action.execute(&mut document); + + // Verify opacity changed + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let obj = vl.get_object(&instance_id).unwrap(); + assert_eq!(obj.opacity, 0.5); + } + + // Rollback + action.rollback(&mut document); + + // Verify restored + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let obj = vl.get_object(&instance_id).unwrap(); + assert_eq!(obj.opacity, 1.0); + } + } + + #[test] + fn test_batch_set_scale() { + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Test Layer"); + + let shape_id = Uuid::new_v4(); + + let mut instance1 = ShapeInstance::new(shape_id); + let instance1_id = instance1.id; + instance1.transform.scale_x = 1.0; + + let mut instance2 = ShapeInstance::new(shape_id); + let instance2_id = instance2.id; + instance2.transform.scale_x = 1.0; + + layer.add_object(instance1); + layer.add_object(instance2); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + // Create and execute batch action + let mut action = SetInstancePropertiesAction::new_batch( + layer_id, + vec![instance1_id, instance2_id], + InstancePropertyChange::ScaleX(2.0), + ); + action.execute(&mut document); + + // Verify both changed + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + assert_eq!(vl.get_object(&instance1_id).unwrap().transform.scale_x, 2.0); + assert_eq!(vl.get_object(&instance2_id).unwrap().transform.scale_x, 2.0); + } + + // Rollback + action.rollback(&mut document); + + // Verify both restored + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + assert_eq!(vl.get_object(&instance1_id).unwrap().transform.scale_x, 1.0); + assert_eq!(vl.get_object(&instance2_id).unwrap().transform.scale_x, 1.0); + } + } + + #[test] + fn test_description() { + let layer_id = Uuid::new_v4(); + let instance_id = Uuid::new_v4(); + + let action1 = SetInstancePropertiesAction::new( + layer_id, + instance_id, + InstancePropertyChange::X(0.0), + ); + assert_eq!(action1.description(), "Set X position"); + + let action2 = SetInstancePropertiesAction::new( + layer_id, + instance_id, + InstancePropertyChange::Rotation(0.0), + ); + assert_eq!(action2.description(), "Set rotation"); + + let action3 = SetInstancePropertiesAction::new_batch( + layer_id, + vec![Uuid::new_v4(), Uuid::new_v4()], + InstancePropertyChange::Opacity(1.0), + ); + assert_eq!(action3.description(), "Set opacity on 2 objects"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs new file mode 100644 index 0000000..3024557 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs @@ -0,0 +1,261 @@ +//! Set shape properties action +//! +//! Handles changing shape properties (fill color, stroke color, stroke width) +//! with undo/redo support. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::shape::{ShapeColor, StrokeStyle}; +use uuid::Uuid; + +/// Property change for a shape +#[derive(Clone, Debug)] +pub enum ShapePropertyChange { + FillColor(Option), + StrokeColor(Option), + StrokeWidth(f64), +} + +/// Action that sets properties on a shape +pub struct SetShapePropertiesAction { + /// Layer containing the shape + layer_id: Uuid, + + /// Shape to modify + shape_id: Uuid, + + /// New property value + new_value: ShapePropertyChange, + + /// Old property value (stored after first execution) + old_value: Option, +} + +impl SetShapePropertiesAction { + /// Create a new action to set a property on a shape + pub fn new(layer_id: Uuid, shape_id: Uuid, new_value: ShapePropertyChange) -> Self { + Self { + layer_id, + shape_id, + new_value, + old_value: None, + } + } + + /// Create action to set fill color + pub fn set_fill_color(layer_id: Uuid, shape_id: Uuid, color: Option) -> Self { + Self::new(layer_id, shape_id, ShapePropertyChange::FillColor(color)) + } + + /// Create action to set stroke color + pub fn set_stroke_color(layer_id: Uuid, shape_id: Uuid, color: Option) -> Self { + Self::new(layer_id, shape_id, ShapePropertyChange::StrokeColor(color)) + } + + /// Create action to set stroke width + pub fn set_stroke_width(layer_id: Uuid, shape_id: Uuid, width: f64) -> Self { + Self::new(layer_id, shape_id, ShapePropertyChange::StrokeWidth(width)) + } +} + +impl Action for SetShapePropertiesAction { + fn execute(&mut self, document: &mut Document) { + if let Some(layer) = document.get_layer_mut(&self.layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) { + // Store old value if not already stored + if self.old_value.is_none() { + self.old_value = Some(match &self.new_value { + ShapePropertyChange::FillColor(_) => { + ShapePropertyChange::FillColor(shape.fill_color) + } + ShapePropertyChange::StrokeColor(_) => { + ShapePropertyChange::StrokeColor(shape.stroke_color) + } + ShapePropertyChange::StrokeWidth(_) => { + let width = shape + .stroke_style + .as_ref() + .map(|s| s.width) + .unwrap_or(1.0); + ShapePropertyChange::StrokeWidth(width) + } + }); + } + + // Apply new value + match &self.new_value { + ShapePropertyChange::FillColor(color) => { + shape.fill_color = *color; + } + ShapePropertyChange::StrokeColor(color) => { + shape.stroke_color = *color; + } + ShapePropertyChange::StrokeWidth(width) => { + if let Some(ref mut style) = shape.stroke_style { + style.width = *width; + } else { + // Create stroke style if it doesn't exist + shape.stroke_style = Some(StrokeStyle { + width: *width, + ..Default::default() + }); + } + } + } + } + } + } + } + + fn rollback(&mut self, document: &mut Document) { + if let Some(old_value) = &self.old_value { + if let Some(layer) = document.get_layer_mut(&self.layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) { + match old_value { + ShapePropertyChange::FillColor(color) => { + shape.fill_color = *color; + } + ShapePropertyChange::StrokeColor(color) => { + shape.stroke_color = *color; + } + ShapePropertyChange::StrokeWidth(width) => { + if let Some(ref mut style) = shape.stroke_style { + style.width = *width; + } + } + } + } + } + } + } + } + + fn description(&self) -> String { + match &self.new_value { + ShapePropertyChange::FillColor(_) => "Set fill color".to_string(), + ShapePropertyChange::StrokeColor(_) => "Set stroke color".to_string(), + ShapePropertyChange::StrokeWidth(_) => "Set stroke width".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::VectorLayer; + use crate::shape::Shape; + use kurbo::BezPath; + + fn create_test_shape() -> Shape { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((100.0, 0.0)); + path.line_to((100.0, 100.0)); + path.line_to((0.0, 100.0)); + path.close_path(); + + let mut shape = Shape::new(path); + shape.fill_color = Some(ShapeColor::rgb(255, 0, 0)); + shape.stroke_color = Some(ShapeColor::rgb(0, 0, 0)); + shape.stroke_style = Some(StrokeStyle { + width: 2.0, + ..Default::default() + }); + shape + } + + #[test] + fn test_set_fill_color() { + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Test Layer"); + + let shape = create_test_shape(); + let shape_id = shape.id; + layer.shapes.insert(shape_id, shape); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + // Verify initial color + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let shape = vl.shapes.get(&shape_id).unwrap(); + assert_eq!(shape.fill_color.unwrap().r, 255); + } + + // Create and execute action + let new_color = Some(ShapeColor::rgb(0, 255, 0)); + let mut action = SetShapePropertiesAction::set_fill_color(layer_id, shape_id, new_color); + action.execute(&mut document); + + // Verify color changed + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let shape = vl.shapes.get(&shape_id).unwrap(); + assert_eq!(shape.fill_color.unwrap().g, 255); + } + + // Rollback + action.rollback(&mut document); + + // Verify restored + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let shape = vl.shapes.get(&shape_id).unwrap(); + assert_eq!(shape.fill_color.unwrap().r, 255); + } + } + + #[test] + fn test_set_stroke_width() { + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Test Layer"); + + let shape = create_test_shape(); + let shape_id = shape.id; + layer.shapes.insert(shape_id, shape); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + // Verify initial width + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let shape = vl.shapes.get(&shape_id).unwrap(); + assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0); + } + + // Create and execute action + let mut action = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 5.0); + action.execute(&mut document); + + // Verify width changed + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let shape = vl.shapes.get(&shape_id).unwrap(); + assert_eq!(shape.stroke_style.as_ref().unwrap().width, 5.0); + } + + // Rollback + action.rollback(&mut document); + + // Verify restored + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let shape = vl.shapes.get(&shape_id).unwrap(); + assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0); + } + } + + #[test] + fn test_description() { + let layer_id = Uuid::new_v4(); + let shape_id = Uuid::new_v4(); + + let action1 = + SetShapePropertiesAction::set_fill_color(layer_id, shape_id, Some(ShapeColor::rgb(0, 0, 0))); + assert_eq!(action1.description(), "Set fill color"); + + let action2 = + SetShapePropertiesAction::set_stroke_color(layer_id, shape_id, Some(ShapeColor::rgb(0, 0, 0))); + assert_eq!(action2.description(), "Set stroke color"); + + let action3 = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 3.0); + assert_eq!(action3.description(), "Set stroke width"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shape.rs b/lightningbeam-ui/lightningbeam-core/src/shape.rs index 25c8c34..f533d9d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shape.rs +++ b/lightningbeam-ui/lightningbeam-core/src/shape.rs @@ -144,7 +144,7 @@ impl StrokeStyle { } /// Serializable color representation -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ShapeColor { pub r: u8, pub g: u8, @@ -238,12 +238,12 @@ pub struct Shape { } impl Shape { - /// Create a new shape with a single path + /// Create a new shape with a single path (no fill or stroke by default) pub fn new(path: BezPath) -> Self { Self { id: Uuid::new_v4(), versions: vec![ShapeVersion::new(path, 0)], - fill_color: Some(ShapeColor::rgb(0, 0, 0)), + fill_color: None, image_fill: None, fill_rule: FillRule::NonZero, stroke_color: None, @@ -251,12 +251,12 @@ impl Shape { } } - /// Create a new shape with a specific ID + /// Create a new shape with a specific ID (no fill or stroke by default) pub fn with_id(id: Uuid, path: BezPath) -> Self { Self { id, versions: vec![ShapeVersion::new(path, 0)], - fill_color: Some(ShapeColor::rgb(0, 0, 0)), + fill_color: None, image_fill: None, fill_rule: FillRule::NonZero, stroke_color: None, diff --git a/lightningbeam-ui/lightningbeam-core/tests/rendering_integration_test.rs b/lightningbeam-ui/lightningbeam-core/tests/rendering_integration_test.rs index 37f2dfa..60cba2a 100644 --- a/lightningbeam-ui/lightningbeam-core/tests/rendering_integration_test.rs +++ b/lightningbeam-ui/lightningbeam-core/tests/rendering_integration_test.rs @@ -7,7 +7,7 @@ use lightningbeam_core::clip::{ClipInstance, VectorClip}; use lightningbeam_core::document::Document; use lightningbeam_core::layer::{AnyLayer, LayerTrait, VectorLayer}; use lightningbeam_core::object::ShapeInstance; -use lightningbeam_core::renderer::{render_document, render_document_with_transform}; +use lightningbeam_core::renderer::{render_document, render_document_with_transform, ImageCache}; use lightningbeam_core::shape::{Shape, ShapeColor}; use vello::kurbo::{Affine, Circle, Shape as KurboShape}; use vello::Scene; @@ -53,28 +53,31 @@ fn setup_rendering_document() -> (Document, Vec) { fn test_render_empty_document() { let document = Document::new("Empty"); let mut scene = Scene::new(); + let mut image_cache = ImageCache::new(); // Should not panic - render_document(&document, &mut scene); + render_document(&document, &mut scene, &mut image_cache); } #[test] fn test_render_document_with_shapes() { let (document, _ids) = setup_rendering_document(); let mut scene = Scene::new(); + let mut image_cache = ImageCache::new(); // Should render all 3 layers without error - render_document(&document, &mut scene); + render_document(&document, &mut scene, &mut image_cache); } #[test] fn test_render_with_transform() { let (document, _ids) = setup_rendering_document(); let mut scene = Scene::new(); + let mut image_cache = ImageCache::new(); // Render with zoom and pan let transform = Affine::translate((100.0, 50.0)) * Affine::scale(2.0); - render_document_with_transform(&document, &mut scene, transform); + render_document_with_transform(&document, &mut scene, transform, &mut image_cache); } #[test] @@ -98,7 +101,8 @@ fn test_render_solo_single_layer() { // Render should work let mut scene = Scene::new(); - render_document(&document, &mut scene); + let mut image_cache = ImageCache::new(); + render_document(&document, &mut scene, &mut image_cache); } #[test] @@ -121,7 +125,8 @@ fn test_render_solo_multiple_layers() { assert_eq!(layers_to_render.len(), 2); let mut scene = Scene::new(); - render_document(&document, &mut scene); + let mut image_cache = ImageCache::new(); + render_document(&document, &mut scene, &mut image_cache); } #[test] @@ -137,7 +142,8 @@ fn test_render_hidden_layer_not_rendered() { assert_eq!(document.visible_layers().count(), 2); let mut scene = Scene::new(); - render_document(&document, &mut scene); + let mut image_cache = ImageCache::new(); + render_document(&document, &mut scene, &mut image_cache); } #[test] @@ -161,7 +167,8 @@ fn test_render_with_layer_opacity() { assert_eq!(document.root.get_child(&ids[2]).unwrap().opacity(), 1.0); let mut scene = Scene::new(); - render_document(&document, &mut scene); + let mut image_cache = ImageCache::new(); + render_document(&document, &mut scene, &mut image_cache); } #[test] @@ -198,7 +205,8 @@ fn test_render_with_clip_instances() { document.set_time(2.0); let mut scene = Scene::new(); - render_document(&document, &mut scene); + let mut image_cache = ImageCache::new(); + render_document(&document, &mut scene, &mut image_cache); } #[test] @@ -223,7 +231,8 @@ fn test_render_clip_instance_outside_time_range() { // Clip shouldn't render (it hasn't started yet) let mut scene = Scene::new(); - render_document(&document, &mut scene); + let mut image_cache = ImageCache::new(); + render_document(&document, &mut scene, &mut image_cache); } #[test] @@ -242,7 +251,8 @@ fn test_render_all_layers_hidden() { // Should still render (just background) let mut scene = Scene::new(); - render_document(&document, &mut scene); + let mut image_cache = ImageCache::new(); + render_document(&document, &mut scene, &mut image_cache); } #[test] @@ -269,7 +279,8 @@ fn test_render_solo_hidden_layer_interaction() { assert_eq!(document.visible_layers().count(), 2); let mut scene = Scene::new(); - render_document(&document, &mut scene); + let mut image_cache = ImageCache::new(); + render_document(&document, &mut scene, &mut image_cache); } #[test] @@ -278,17 +289,19 @@ fn test_render_background_color() { document.background_color = ShapeColor::rgb(128, 128, 128); let mut scene = Scene::new(); - render_document(&document, &mut scene); + let mut image_cache = ImageCache::new(); + render_document(&document, &mut scene, &mut image_cache); } #[test] fn test_render_at_different_times() { let (mut document, _ids) = setup_rendering_document(); + let mut image_cache = ImageCache::new(); // Render at different times for time in [0.0, 0.5, 1.0, 2.5, 5.0, 10.0] { document.set_time(time); let mut scene = Scene::new(); - render_document(&document, &mut scene); + render_document(&document, &mut scene, &mut image_cache); } } diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index 7d1cc14..0e9296e 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -32,6 +32,7 @@ serde_json = { workspace = true } # Image loading image = { workspace = true } resvg = { workspace = true } +tiny-skia = "0.11" # Utilities pollster = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 153fa49..76d452c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -277,6 +277,11 @@ struct EditorApp { dragging_asset: Option, // Asset being dragged from Asset Library // Import dialog state last_import_filter: ImportFilter, // Last used import filter (remembered across imports) + // Tool-specific options (displayed in infopanel) + stroke_width: f64, // Stroke width for drawing tools (default: 3.0) + fill_enabled: bool, // Whether to fill shapes (default: true) + paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0) + polygon_sides: u32, // Number of sides for polygon tool (default: 5) } /// Import filter types for the file dialog @@ -308,10 +313,11 @@ impl EditorApp { use lightningbeam_core::shape::{Shape, ShapeColor}; use vello::kurbo::{Circle, Shape as KurboShape}; - let circle = Circle::new((200.0, 150.0), 50.0); + // Create circle centered at origin, position via instance transform + let circle = Circle::new((0.0, 0.0), 50.0); let path = circle.to_path(0.1); let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); - let object = ShapeInstance::new(shape.id); + let object = ShapeInstance::new(shape.id).with_position(200.0, 150.0); let mut vector_layer = VectorLayer::new("Layer 1"); vector_layer.add_shape(shape); @@ -364,6 +370,10 @@ impl EditorApp { is_playing: false, // Start paused dragging_asset: None, // No asset being dragged initially last_import_filter: ImportFilter::default(), // Default to "All Supported" + stroke_width: 3.0, // Default stroke width + fill_enabled: true, // Default to filling shapes + paint_bucket_gap_tolerance: 5.0, // Default gap tolerance + polygon_sides: 5, // Default to pentagon } } @@ -981,6 +991,10 @@ impl eframe::App for EditorApp { playback_time: &mut self.playback_time, is_playing: &mut self.is_playing, dragging_asset: &mut self.dragging_asset, + stroke_width: &mut self.stroke_width, + fill_enabled: &mut self.fill_enabled, + paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, + polygon_sides: &mut self.polygon_sides, }; render_layout_node( @@ -1121,6 +1135,11 @@ struct RenderContext<'a> { playback_time: &'a mut f64, is_playing: &'a mut bool, dragging_asset: &'a mut Option, + // Tool-specific options for infopanel + stroke_width: &'a mut f64, + fill_enabled: &'a mut bool, + paint_bucket_gap_tolerance: &'a mut f64, + polygon_sides: &'a mut u32, } /// Recursively render a layout node with drag support @@ -1586,6 +1605,10 @@ fn render_pane( playback_time: ctx.playback_time, is_playing: ctx.is_playing, dragging_asset: ctx.dragging_asset, + stroke_width: ctx.stroke_width, + fill_enabled: ctx.fill_enabled, + paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance, + polygon_sides: ctx.polygon_sides, }; pane_instance.render_header(&mut header_ui, &mut shared); } @@ -1634,6 +1657,10 @@ fn render_pane( playback_time: ctx.playback_time, is_playing: ctx.is_playing, dragging_asset: ctx.dragging_asset, + stroke_width: ctx.stroke_width, + fill_enabled: ctx.fill_enabled, + paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance, + polygon_sides: ctx.polygon_sides, }; // Render pane content (header was already rendered above) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index fe7a6d8..ca3b509 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -4,20 +4,461 @@ //! - Vector Clips (animations) //! - Video Clips (imported video files) //! - Audio Clips (sampled audio and MIDI) +//! - Image Assets (static images) use eframe::egui; -use lightningbeam_core::clip::AudioClipType; +use lightningbeam_core::clip::{AudioClipType, VectorClip}; use lightningbeam_core::document::Document; +use lightningbeam_core::layer::AnyLayer; +use lightningbeam_core::shape::ShapeColor; +use std::collections::{HashMap, HashSet}; use uuid::Uuid; use super::{DragClipType, DraggingAsset, NodePath, PaneRenderer, SharedPaneState}; use crate::widgets::ImeTextField; +// Thumbnail constants +const THUMBNAIL_SIZE: u32 = 64; +const THUMBNAIL_PREVIEW_SECONDS: f64 = 10.0; + // Layout constants const SEARCH_BAR_HEIGHT: f32 = 30.0; const CATEGORY_TAB_HEIGHT: f32 = 28.0; const ITEM_HEIGHT: f32 = 40.0; const ITEM_PADDING: f32 = 4.0; +const LIST_THUMBNAIL_SIZE: f32 = 32.0; +const GRID_ITEM_SIZE: f32 = 80.0; +const GRID_SPACING: f32 = 8.0; + +/// View mode for the asset library +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AssetViewMode { + #[default] + List, + Grid, +} + +/// Cache for thumbnail textures +pub struct ThumbnailCache { + /// Cached egui textures keyed by asset UUID + textures: HashMap, + /// Track which assets need regeneration + dirty: HashSet, +} + +impl Default for ThumbnailCache { + fn default() -> Self { + Self::new() + } +} + +impl ThumbnailCache { + pub fn new() -> Self { + Self { + textures: HashMap::new(), + dirty: HashSet::new(), + } + } + + /// Get a cached thumbnail or create one using the provided generator + pub fn get_or_create( + &mut self, + ctx: &egui::Context, + asset_id: Uuid, + generator: F, + ) -> Option<&egui::TextureHandle> + where + F: FnOnce() -> Option>, + { + // Check if we need to regenerate + if self.dirty.contains(&asset_id) { + self.textures.remove(&asset_id); + self.dirty.remove(&asset_id); + } + + // Return cached texture if available + if self.textures.contains_key(&asset_id) { + return self.textures.get(&asset_id); + } + + // Generate new thumbnail + if let Some(rgba_data) = generator() { + let color_image = egui::ColorImage::from_rgba_unmultiplied( + [THUMBNAIL_SIZE as usize, THUMBNAIL_SIZE as usize], + &rgba_data, + ); + let texture = ctx.load_texture( + format!("thumbnail_{}", asset_id), + color_image, + egui::TextureOptions::LINEAR, + ); + self.textures.insert(asset_id, texture); + return self.textures.get(&asset_id); + } + + None + } + + /// Check if a thumbnail is already cached (and not dirty) + pub fn has(&self, asset_id: &Uuid) -> bool { + self.textures.contains_key(asset_id) && !self.dirty.contains(asset_id) + } + + /// Mark an asset's thumbnail as needing regeneration + pub fn invalidate(&mut self, asset_id: &Uuid) { + self.dirty.insert(*asset_id); + } + + /// Clear all cached thumbnails + pub fn clear(&mut self) { + self.textures.clear(); + self.dirty.clear(); + } +} + +// ============================================================================ +// Thumbnail Generation Functions +// ============================================================================ + +/// Generate a 64x64 RGBA thumbnail for an image asset +fn generate_image_thumbnail(asset: &lightningbeam_core::clip::ImageAsset) -> Option> { + let data = asset.data.as_ref()?; + + // Decode the image + let img = image::load_from_memory(data).ok()?; + + // Resize to thumbnail size using Lanczos3 filter for quality + let thumbnail = img.resize_exact( + THUMBNAIL_SIZE, + THUMBNAIL_SIZE, + image::imageops::FilterType::Lanczos3, + ); + + // Convert to RGBA8 + Some(thumbnail.to_rgba8().into_raw()) +} + +/// Generate a placeholder thumbnail with a solid color and optional icon indication +fn generate_placeholder_thumbnail(category: AssetCategory, bg_alpha: u8) -> Vec { + let size = THUMBNAIL_SIZE as usize; + let mut rgba = vec![0u8; size * size * 4]; + + // Get category color for the placeholder + let color = category.color(); + + // Fill with semi-transparent background + for pixel in rgba.chunks_mut(4) { + pixel[0] = 40; + pixel[1] = 40; + pixel[2] = 40; + pixel[3] = bg_alpha; + } + + // Draw a simple icon/indicator in the center based on category + let center = size / 2; + let icon_size = size / 3; + + match category { + AssetCategory::Video => { + // Draw a play triangle + for y in 0..icon_size { + let row_width = (y * icon_size / icon_size).max(1); + for x in 0..row_width { + let px = center - icon_size / 4 + x; + let py = center - icon_size / 2 + y; + if px < size && py < size { + let idx = (py * size + px) * 4; + rgba[idx] = color.r(); + rgba[idx + 1] = color.g(); + rgba[idx + 2] = color.b(); + rgba[idx + 3] = 255; + } + } + } + } + _ => { + // Draw a simple rectangle + let half = icon_size / 2; + for y in (center - half)..(center + half) { + for x in (center - half)..(center + half) { + if x < size && y < size { + let idx = (y * size + x) * 4; + rgba[idx] = color.r(); + rgba[idx + 1] = color.g(); + rgba[idx + 2] = color.b(); + rgba[idx + 3] = 200; + } + } + } + } + } + + rgba +} + +/// Helper function to fill a thumbnail buffer with a background color +fn fill_thumbnail_background(rgba: &mut [u8], color: egui::Color32) { + for pixel in rgba.chunks_mut(4) { + pixel[0] = color.r(); + pixel[1] = color.g(); + pixel[2] = color.b(); + pixel[3] = color.a(); + } +} + +/// Helper function to draw a rectangle on the thumbnail buffer +fn draw_thumbnail_rect( + rgba: &mut [u8], + width: usize, + x: usize, + y: usize, + w: usize, + h: usize, + color: egui::Color32, +) { + for dy in 0..h { + for dx in 0..w { + let px = x + dx; + let py = y + dy; + if px < width && py < width { + let idx = (py * width + px) * 4; + if idx + 3 < rgba.len() { + rgba[idx] = color.r(); + rgba[idx + 1] = color.g(); + rgba[idx + 2] = color.b(); + rgba[idx + 3] = color.a(); + } + } + } + } +} + +/// Generate a waveform thumbnail for sampled audio +/// Shows the first THUMBNAIL_PREVIEW_SECONDS of audio to avoid solid blobs for long clips +fn generate_waveform_thumbnail( + waveform_peaks: &[(f32, f32)], // (min, max) pairs + bg_color: egui::Color32, + wave_color: egui::Color32, +) -> Vec { + let size = THUMBNAIL_SIZE as usize; + let mut rgba = vec![0u8; size * size * 4]; + + // Fill background + fill_thumbnail_background(&mut rgba, bg_color); + + // Draw waveform + let center_y = size / 2; + let num_peaks = waveform_peaks.len().min(size); + + for (x, &(min_val, max_val)) in waveform_peaks.iter().take(size).enumerate() { + // Scale peaks to pixel range (center ± half height) + let min_y = (center_y as f32 + min_val * center_y as f32) as usize; + let max_y = (center_y as f32 + max_val * center_y as f32) as usize; + + let y_start = min_y.min(max_y).min(size - 1); + let y_end = min_y.max(max_y).min(size - 1); + + for y in y_start..=y_end { + let idx = (y * size + x) * 4; + if idx + 3 < rgba.len() { + rgba[idx] = wave_color.r(); + rgba[idx + 1] = wave_color.g(); + rgba[idx + 2] = wave_color.b(); + rgba[idx + 3] = 255; + } + } + } + + rgba +} + +/// Generate a piano roll thumbnail for MIDI clips +/// Shows notes as horizontal bars with Y position = note % 12 (one octave) +fn generate_midi_thumbnail( + events: &[(f64, u8, bool)], // (timestamp, note_number, is_note_on) + duration: f64, + bg_color: egui::Color32, + note_color: egui::Color32, +) -> Vec { + let size = THUMBNAIL_SIZE as usize; + let mut rgba = vec![0u8; size * size * 4]; + + // Fill background + fill_thumbnail_background(&mut rgba, bg_color); + + // Limit to first 10 seconds + let preview_duration = duration.min(THUMBNAIL_PREVIEW_SECONDS); + if preview_duration <= 0.0 { + return rgba; + } + + // Draw note events + for &(timestamp, note_number, is_note_on) in events { + if !is_note_on || timestamp > preview_duration { + continue; + } + + let x = ((timestamp / preview_duration) * size as f64) as usize; + + // Note position: modulo 12 (one octave), mapped to full height + // Note 0 (C) at bottom, Note 11 (B) at top + let note_in_octave = note_number % 12; + let y = size - 1 - (note_in_octave as usize * size / 12); + + // Draw a small rectangle for the note + draw_thumbnail_rect(&mut rgba, size, x.min(size - 2), y.saturating_sub(2), 2, 4, note_color); + } + + rgba +} + +/// Generate a 64x64 RGBA thumbnail for a vector clip +/// Renders frame 0 of the clip using tiny-skia for software rendering +fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec { + use kurbo::PathEl; + use tiny_skia::{Paint, PathBuilder, Pixmap, Transform as TsTransform}; + + let size = THUMBNAIL_SIZE as usize; + let mut pixmap = Pixmap::new(THUMBNAIL_SIZE, THUMBNAIL_SIZE) + .unwrap_or_else(|| Pixmap::new(1, 1).unwrap()); + + // Fill background + pixmap.fill(tiny_skia::Color::from_rgba8( + bg_color.r(), + bg_color.g(), + bg_color.b(), + bg_color.a(), + )); + + // Calculate scale to fit clip dimensions into thumbnail + let scale_x = THUMBNAIL_SIZE as f64 / clip.width.max(1.0); + let scale_y = THUMBNAIL_SIZE as f64 / clip.height.max(1.0); + let scale = scale_x.min(scale_y) * 0.9; // 90% to leave a small margin + + // Center offset + let offset_x = (THUMBNAIL_SIZE as f64 - clip.width * scale) / 2.0; + let offset_y = (THUMBNAIL_SIZE as f64 - clip.height * scale) / 2.0; + + // Iterate through layers and render shapes + for layer_node in clip.layers.iter() { + if let AnyLayer::Vector(vector_layer) = &layer_node.data { + // Render each shape instance + for shape_instance in &vector_layer.shape_instances { + if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) { + // Get the path (frame 0) + let kurbo_path = shape.path(); + + // Convert kurbo BezPath to tiny-skia PathBuilder + let mut path_builder = PathBuilder::new(); + for el in kurbo_path.iter() { + match el { + PathEl::MoveTo(p) => { + let x = (p.x * scale + offset_x) as f32; + let y = (p.y * scale + offset_y) as f32; + path_builder.move_to(x, y); + } + PathEl::LineTo(p) => { + let x = (p.x * scale + offset_x) as f32; + let y = (p.y * scale + offset_y) as f32; + path_builder.line_to(x, y); + } + PathEl::QuadTo(p1, p2) => { + let x1 = (p1.x * scale + offset_x) as f32; + let y1 = (p1.y * scale + offset_y) as f32; + let x2 = (p2.x * scale + offset_x) as f32; + let y2 = (p2.y * scale + offset_y) as f32; + path_builder.quad_to(x1, y1, x2, y2); + } + PathEl::CurveTo(p1, p2, p3) => { + let x1 = (p1.x * scale + offset_x) as f32; + let y1 = (p1.y * scale + offset_y) as f32; + let x2 = (p2.x * scale + offset_x) as f32; + let y2 = (p2.y * scale + offset_y) as f32; + let x3 = (p3.x * scale + offset_x) as f32; + let y3 = (p3.y * scale + offset_y) as f32; + path_builder.cubic_to(x1, y1, x2, y2, x3, y3); + } + PathEl::ClosePath => { + path_builder.close(); + } + } + } + + if let Some(ts_path) = path_builder.finish() { + // Draw fill if present + if let Some(fill_color) = &shape.fill_color { + let mut paint = Paint::default(); + paint.set_color(shape_color_to_tiny_skia(fill_color)); + paint.anti_alias = true; + pixmap.fill_path( + &ts_path, + &paint, + tiny_skia::FillRule::Winding, + TsTransform::identity(), + None, + ); + } + + // Draw stroke if present + if let Some(stroke_color) = &shape.stroke_color { + if let Some(stroke_style) = &shape.stroke_style { + let mut paint = Paint::default(); + paint.set_color(shape_color_to_tiny_skia(stroke_color)); + paint.anti_alias = true; + + let stroke = tiny_skia::Stroke { + width: (stroke_style.width * scale) as f32, + ..Default::default() + }; + + pixmap.stroke_path( + &ts_path, + &paint, + &stroke, + TsTransform::identity(), + None, + ); + } + } + } + } + } + } + } + + // Convert to RGBA bytes + let data = pixmap.data(); + // tiny-skia uses premultiplied RGBA, need to convert to straight alpha for egui + let mut rgba = Vec::with_capacity(size * size * 4); + for chunk in data.chunks(4) { + let a = chunk[3] as f32 / 255.0; + if a > 0.0 { + // Unpremultiply + rgba.push((chunk[0] as f32 / a).min(255.0) as u8); + rgba.push((chunk[1] as f32 / a).min(255.0) as u8); + rgba.push((chunk[2] as f32 / a).min(255.0) as u8); + rgba.push(chunk[3]); + } else { + rgba.extend_from_slice(chunk); + } + } + rgba +} + +/// Convert ShapeColor to tiny_skia Color +fn shape_color_to_tiny_skia(color: &ShapeColor) -> tiny_skia::Color { + tiny_skia::Color::from_rgba8(color.r, color.g, color.b, color.a) +} + +/// Ellipsize a string to fit within a maximum character count +fn ellipsize(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect(); + format!("{}...", truncated) + } +} /// Asset category for filtering #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -117,6 +558,12 @@ pub struct AssetLibraryPane { /// Active rename state rename_state: Option, + + /// Current view mode (list or grid) + view_mode: AssetViewMode, + + /// Thumbnail texture cache + thumbnail_cache: ThumbnailCache, } impl AssetLibraryPane { @@ -128,6 +575,8 @@ impl AssetLibraryPane { context_menu: None, pending_delete: None, rename_state: None, + view_mode: AssetViewMode::default(), + thumbnail_cache: ThumbnailCache::new(), } } @@ -301,7 +750,7 @@ impl AssetLibraryPane { } } - /// Render the search bar at the top + /// Render the search bar at the top with view toggle buttons fn render_search_bar(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &SharedPaneState) { let search_rect = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), SEARCH_BAR_HEIGHT)); @@ -313,6 +762,104 @@ impl AssetLibraryPane { .unwrap_or(egui::Color32::from_rgb(30, 30, 30)); ui.painter().rect_filled(search_rect, 0.0, bg_color); + // View toggle buttons on the right (list and grid icons) + let button_size = 20.0; + let button_padding = 4.0; + let buttons_width = button_size * 2.0 + button_padding * 3.0; + + // Grid view button (rightmost) + let grid_button_rect = egui::Rect::from_min_size( + egui::pos2( + search_rect.max.x - button_size - button_padding, + search_rect.min.y + (SEARCH_BAR_HEIGHT - button_size) / 2.0, + ), + egui::vec2(button_size, button_size), + ); + + // List view button + let list_button_rect = egui::Rect::from_min_size( + egui::pos2( + grid_button_rect.min.x - button_size - button_padding, + search_rect.min.y + (SEARCH_BAR_HEIGHT - button_size) / 2.0, + ), + egui::vec2(button_size, button_size), + ); + + // Draw and handle list button + let list_selected = self.view_mode == AssetViewMode::List; + let list_response = ui.allocate_rect(list_button_rect, egui::Sense::click()); + let list_bg = if list_selected { + egui::Color32::from_rgb(70, 90, 110) + } else if list_response.hovered() { + egui::Color32::from_rgb(50, 50, 50) + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(list_button_rect, 3.0, list_bg); + + // Draw list icon (three horizontal lines) + let list_icon_color = if list_selected { + egui::Color32::WHITE + } else { + egui::Color32::from_gray(150) + }; + let line_spacing = 4.0; + let line_width = 10.0; + let line_x = list_button_rect.center().x - line_width / 2.0; + for i in 0..3 { + let line_y = list_button_rect.center().y - line_spacing + (i as f32 * line_spacing); + ui.painter().line_segment( + [ + egui::pos2(line_x, line_y), + egui::pos2(line_x + line_width, line_y), + ], + egui::Stroke::new(1.5, list_icon_color), + ); + } + + if list_response.clicked() { + self.view_mode = AssetViewMode::List; + } + + // Draw and handle grid button + let grid_selected = self.view_mode == AssetViewMode::Grid; + let grid_response = ui.allocate_rect(grid_button_rect, egui::Sense::click()); + let grid_bg = if grid_selected { + egui::Color32::from_rgb(70, 90, 110) + } else if grid_response.hovered() { + egui::Color32::from_rgb(50, 50, 50) + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(grid_button_rect, 3.0, grid_bg); + + // Draw grid icon (2x2 squares) + let grid_icon_color = if grid_selected { + egui::Color32::WHITE + } else { + egui::Color32::from_gray(150) + }; + let square_size = 4.0; + let square_gap = 2.0; + let grid_start_x = grid_button_rect.center().x - square_size - square_gap / 2.0; + let grid_start_y = grid_button_rect.center().y - square_size - square_gap / 2.0; + for row in 0..2 { + for col in 0..2 { + let square_rect = egui::Rect::from_min_size( + egui::pos2( + grid_start_x + col as f32 * (square_size + square_gap), + grid_start_y + row as f32 * (square_size + square_gap), + ), + egui::vec2(square_size, square_size), + ); + ui.painter().rect_filled(square_rect, 1.0, grid_icon_color); + } + } + + if grid_response.clicked() { + self.view_mode = AssetViewMode::Grid; + } + // Label position let label_pos = search_rect.min + egui::vec2(8.0, (SEARCH_BAR_HEIGHT - 14.0) / 2.0); ui.painter().text( @@ -323,10 +870,10 @@ impl AssetLibraryPane { egui::Color32::from_gray(180), ); - // Text field using IME-safe widget + // Text field using IME-safe widget (leave room for view toggle buttons) let text_edit_rect = egui::Rect::from_min_size( search_rect.min + egui::vec2(65.0, 4.0), - egui::vec2(search_rect.width() - 75.0, SEARCH_BAR_HEIGHT - 8.0), + egui::vec2(search_rect.width() - 75.0 - buttons_width, SEARCH_BAR_HEIGHT - 8.0), ); let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_edit_rect)); @@ -408,13 +955,33 @@ impl AssetLibraryPane { } } - /// Render the asset list - fn render_asset_list( + /// Render assets based on current view mode + fn render_assets( &mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState, assets: &[&AssetEntry], + document: &Document, + ) { + match self.view_mode { + AssetViewMode::List => { + self.render_asset_list_view(ui, rect, shared, assets, document); + } + AssetViewMode::Grid => { + self.render_asset_grid_view(ui, rect, shared, assets, document); + } + } + } + + /// Render the asset list view + fn render_asset_list_view( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + shared: &mut SharedPaneState, + assets: &[&AssetEntry], + document: &Document, ) { // Background let bg_style = shared.theme.style(".panel-content", ui.ctx()); @@ -539,6 +1106,105 @@ impl AssetLibraryPane { secondary_text_color, ); + // Thumbnail on the right side + let thumbnail_rect = egui::Rect::from_min_size( + egui::pos2( + item_rect.max.x - LIST_THUMBNAIL_SIZE - 4.0, + item_rect.min.y + (ITEM_HEIGHT - LIST_THUMBNAIL_SIZE) / 2.0, + ), + egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE), + ); + + // Generate and display thumbnail based on asset type + let asset_id = asset.id; + let asset_category = asset.category; + let ctx = ui.ctx().clone(); + + // Only pre-fetch waveform data if thumbnail not already cached + // (get_pool_waveform is expensive - it blocks waiting for audio thread) + let prefetched_waveform: Option> = + if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { + if let Some(clip) = document.audio_clips.get(&asset_id) { + if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + if let Some(audio_controller) = shared.audio_controller.as_mut() { + audio_controller.get_pool_waveform(*audio_pool_index, THUMBNAIL_SIZE as usize) + .ok() + .map(|peaks| peaks.iter().map(|p| (p.min, p.max)).collect()) + } else { + None + } + } 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 => { + // Video backend not implemented yet - use placeholder + Some(generate_placeholder_thumbnail(AssetCategory::Video, 200)) + } + AssetCategory::Audio => { + // Check if it's sampled or MIDI + if let Some(clip) = document.audio_clips.get(&asset_id) { + let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); + match &clip.clip_type { + AudioClipType::Sampled { .. } => { + let wave_color = egui::Color32::from_rgb(100, 200, 100); + if let Some(ref peaks) = prefetched_waveform { + Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) + } else { + Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) + } + } + AudioClipType::Midi { events, .. } => { + let note_color = egui::Color32::from_rgb(100, 150, 255); + // Convert MIDI events to (timestamp, note, is_note_on) tuples + // Note on: 0x90-0x9F, Note off: 0x80-0x8F + let midi_events: Vec<(f64, u8, bool)> = events.iter() + .filter_map(|e| { + let msg_type = e.status & 0xF0; + let is_note_on = msg_type == 0x90 && e.data2 > 0; + let is_note_off = msg_type == 0x80 || (msg_type == 0x90 && e.data2 == 0); + if is_note_on || is_note_off { + Some((e.timestamp, e.data1, is_note_on)) + } else { + None + } + }) + .collect(); + Some(generate_midi_thumbnail(&midi_events, clip.duration, bg_color, note_color)) + } + } + } else { + Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) + } + } + AssetCategory::All => None, + } + }); + + if let Some(texture) = texture { + let image = egui::Image::new(texture) + .fit_to_exact_size(egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE)); + ui.put(thumbnail_rect, image); + } + // Handle click (selection) if response.clicked() { self.selected_asset = Some(asset.id); @@ -622,6 +1288,295 @@ impl AssetLibraryPane { } } } + + /// Render the asset grid view + fn render_asset_grid_view( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + shared: &mut SharedPaneState, + assets: &[&AssetEntry], + document: &Document, + ) { + // Background + let bg_style = shared.theme.style(".panel-content", ui.ctx()); + let bg_color = bg_style + .background_color + .unwrap_or(egui::Color32::from_rgb(25, 25, 25)); + ui.painter().rect_filled(rect, 0.0, bg_color); + + // Text color + let text_style = shared.theme.style(".text-primary", ui.ctx()); + let text_color = text_style + .text_color + .unwrap_or(egui::Color32::from_gray(200)); + let secondary_text_color = egui::Color32::from_gray(120); + + // Show empty state message if no assets + if assets.is_empty() { + let message = if !self.search_filter.is_empty() { + "No assets match your search" + } else { + "No assets in this category" + }; + + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + message, + egui::FontId::proportional(14.0), + secondary_text_color, + ); + return; + } + + // Calculate grid layout + let content_width = rect.width() - 16.0; // Account for scrollbar + let columns = ((content_width + GRID_SPACING) / (GRID_ITEM_SIZE + GRID_SPACING)) + .floor() + .max(1.0) as usize; + let item_height = GRID_ITEM_SIZE + 20.0; // 20 for name below thumbnail + 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 + ui.allocate_ui_at_rect(rect, |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + // Reserve space for the entire grid + let (grid_rect, _) = ui.allocate_exact_size( + egui::vec2(content_width, total_height), + egui::Sense::hover(), + ); + + for (idx, asset) in assets.iter().enumerate() { + let col = idx % columns; + let row = idx / columns; + + // Calculate item position with proper spacing + let item_x = grid_rect.min.x + GRID_SPACING + col as f32 * (GRID_ITEM_SIZE + GRID_SPACING); + let item_y = grid_rect.min.y + GRID_SPACING + row as f32 * (item_height + GRID_SPACING); + + let item_rect = egui::Rect::from_min_size( + egui::pos2(item_x, item_y), + egui::vec2(GRID_ITEM_SIZE, item_height), + ); + + // Allocate the response for this item + let response = ui.allocate_rect(item_rect, egui::Sense::click_and_drag()); + + let is_selected = self.selected_asset == Some(asset.id); + let is_being_dragged = shared + .dragging_asset + .as_ref() + .map(|d| d.clip_id == asset.id) + .unwrap_or(false); + + // Item background + let item_bg = if is_being_dragged { + egui::Color32::from_rgb(80, 100, 120) + } else if is_selected { + egui::Color32::from_rgb(60, 80, 100) + } else if response.hovered() { + egui::Color32::from_rgb(45, 45, 45) + } else { + egui::Color32::from_rgb(35, 35, 35) + }; + ui.painter().rect_filled(item_rect, 4.0, item_bg); + + // Thumbnail area (64x64 centered in 80px width) + let thumbnail_rect = egui::Rect::from_min_size( + egui::pos2( + item_rect.min.x + (GRID_ITEM_SIZE - THUMBNAIL_SIZE as f32) / 2.0, + item_rect.min.y + 4.0, + ), + egui::vec2(THUMBNAIL_SIZE as f32, THUMBNAIL_SIZE as f32), + ); + + // Generate and display thumbnail based on asset type + let asset_id = asset.id; + let asset_category = asset.category; + let ctx = ui.ctx().clone(); + + // Only pre-fetch waveform data if thumbnail not already cached + // (get_pool_waveform is expensive - it blocks waiting for audio thread) + let prefetched_waveform: Option> = + if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { + if let Some(clip) = document.audio_clips.get(&asset_id) { + if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + if let Some(audio_controller) = shared.audio_controller.as_mut() { + audio_controller.get_pool_waveform(*audio_pool_index, THUMBNAIL_SIZE as usize) + .ok() + .map(|peaks| peaks.iter().map(|p| (p.min, p.max)).collect()) + } else { + None + } + } 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 => { + 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 { events, .. } => { + let note_color = egui::Color32::from_rgb(100, 150, 255); + let midi_events: Vec<(f64, u8, bool)> = events.iter() + .filter_map(|e| { + let msg_type = e.status & 0xF0; + let is_note_on = msg_type == 0x90 && e.data2 > 0; + let is_note_off = msg_type == 0x80 || (msg_type == 0x90 && e.data2 == 0); + if is_note_on || is_note_off { + Some((e.timestamp, e.data1, is_note_on)) + } else { + None + } + }) + .collect(); + Some(generate_midi_thumbnail(&midi_events, clip.duration, bg_color, note_color)) + } + } + } else { + Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) + } + } + 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() { + *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, + }); + } + } + }); + }); + + // Draw drag preview at cursor when dragging + if let Some(dragging) = shared.dragging_asset.as_ref() { + if let Some(pos) = ui.ctx().pointer_interact_pos() { + let preview_rect = egui::Rect::from_min_size( + pos + egui::vec2(10.0, 10.0), + egui::vec2(150.0, 30.0), + ); + + let painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Tooltip, + egui::Id::new("drag_preview"), + )); + + painter.rect_filled( + preview_rect, + 4.0, + egui::Color32::from_rgba_unmultiplied(60, 60, 60, 220), + ); + + painter.text( + preview_rect.center(), + egui::Align2::CENTER_CENTER, + &dragging.name, + egui::FontId::proportional(12.0), + egui::Color32::WHITE, + ); + } + } + + // Clear drag state when mouse is released + if ui.input(|i| i.pointer.any_released()) { + if let Some(pos) = ui.ctx().pointer_interact_pos() { + if rect.contains(pos) { + *shared.dragging_asset = None; + } + } + } + } } impl PaneRenderer for AssetLibraryPane { @@ -632,10 +1587,12 @@ impl PaneRenderer for AssetLibraryPane { _path: &NodePath, shared: &mut SharedPaneState, ) { - let document = shared.action_executor.document(); + // Get an Arc clone of the document for thumbnail generation + // This allows us to pass &mut shared to render functions while still accessing document + let document_arc = shared.action_executor.document_arc(); // Collect and filter assets - let all_assets = self.collect_assets(document); + let all_assets = self.collect_assets(&document_arc); let filtered_assets = self.filter_assets(&all_assets); // Layout: Search bar -> Category tabs -> Asset list @@ -655,7 +1612,7 @@ impl PaneRenderer for AssetLibraryPane { // Render components self.render_search_bar(ui, search_rect, shared); self.render_category_tabs(ui, tabs_rect, shared); - self.render_asset_list(ui, list_rect, shared, &filtered_assets); + self.render_assets(ui, list_rect, shared, &filtered_assets, &document_arc); // Context menu handling if let Some(ref context_state) = self.context_menu.clone() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 28853e3..7b36802 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1,16 +1,752 @@ -/// Info Panel pane - displays properties of selected objects +/// Info Panel pane - displays and edits properties of selected objects /// -/// This will eventually show editable properties. -/// For now, it's a placeholder. +/// Shows context-sensitive property editors based on current selection: +/// - Tool options (when a tool is active) +/// - Transform properties (when shapes are selected) +/// - Shape properties (fill/stroke for selected shapes) +/// - Document settings (when nothing is selected) -use eframe::egui; +use eframe::egui::{self, DragValue, Sense, Ui}; +use lightningbeam_core::actions::{ + InstancePropertyChange, SetDocumentPropertiesAction, SetInstancePropertiesAction, + SetShapePropertiesAction, +}; +use lightningbeam_core::layer::AnyLayer; +use lightningbeam_core::shape::ShapeColor; +use lightningbeam_core::tool::{SimplifyMode, Tool}; use super::{NodePath, PaneRenderer, SharedPaneState}; +use uuid::Uuid; -pub struct InfopanelPane {} +/// Info panel pane state +pub struct InfopanelPane { + /// Whether the tool options section is expanded + tool_section_open: bool, + /// Whether the transform section is expanded + transform_section_open: bool, + /// Whether the shape properties section is expanded + shape_section_open: bool, +} impl InfopanelPane { pub fn new() -> Self { - Self {} + Self { + tool_section_open: true, + transform_section_open: true, + shape_section_open: true, + } + } +} + +/// Aggregated info about the current selection +struct SelectionInfo { + /// True if nothing is selected + is_empty: bool, + /// Number of selected shape instances + shape_count: usize, + /// Layer ID of selected shapes (assumes single layer selection for now) + layer_id: Option, + /// Selected shape instance IDs + instance_ids: Vec, + /// Shape IDs referenced by selected instances + shape_ids: Vec, + + // Transform values (None = mixed values across selection) + x: Option, + y: Option, + rotation: Option, + scale_x: Option, + scale_y: Option, + skew_x: Option, + skew_y: Option, + opacity: Option, + + // Shape property values (None = mixed) + fill_color: Option>, + stroke_color: Option>, + stroke_width: Option, +} + +impl Default for SelectionInfo { + fn default() -> Self { + Self { + is_empty: true, + shape_count: 0, + layer_id: None, + instance_ids: Vec::new(), + shape_ids: Vec::new(), + x: None, + y: None, + rotation: None, + scale_x: None, + scale_y: None, + skew_x: None, + skew_y: None, + opacity: None, + fill_color: None, + stroke_color: None, + stroke_width: None, + } + } +} + +impl InfopanelPane { + /// Gather info about the current selection + fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo { + let mut info = SelectionInfo::default(); + + let selected_instances = shared.selection.shape_instances(); + info.shape_count = selected_instances.len(); + info.is_empty = info.shape_count == 0; + + if info.is_empty { + return info; + } + + info.instance_ids = selected_instances.to_vec(); + + // Find the layer containing the selected instances + let document = shared.action_executor.document(); + let active_layer_id = *shared.active_layer_id; + + if let Some(layer_id) = active_layer_id { + info.layer_id = Some(layer_id); + + if let Some(layer) = document.get_layer(&layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + // Gather values from all selected instances + let mut first = true; + + for instance_id in &info.instance_ids { + if let Some(instance) = vector_layer.get_object(instance_id) { + info.shape_ids.push(instance.shape_id); + + if first { + // First instance - set initial values + info.x = Some(instance.transform.x); + info.y = Some(instance.transform.y); + info.rotation = Some(instance.transform.rotation); + info.scale_x = Some(instance.transform.scale_x); + info.scale_y = Some(instance.transform.scale_y); + info.skew_x = Some(instance.transform.skew_x); + info.skew_y = Some(instance.transform.skew_y); + info.opacity = Some(instance.opacity); + + // Get shape properties + if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) { + info.fill_color = Some(shape.fill_color); + info.stroke_color = Some(shape.stroke_color); + info.stroke_width = shape + .stroke_style + .as_ref() + .map(|s| Some(s.width)) + .unwrap_or(Some(1.0)); + } + + first = false; + } else { + // Check if values differ (set to None if mixed) + if info.x != Some(instance.transform.x) { + info.x = None; + } + if info.y != Some(instance.transform.y) { + info.y = None; + } + if info.rotation != Some(instance.transform.rotation) { + info.rotation = None; + } + if info.scale_x != Some(instance.transform.scale_x) { + info.scale_x = None; + } + if info.scale_y != Some(instance.transform.scale_y) { + info.scale_y = None; + } + if info.skew_x != Some(instance.transform.skew_x) { + info.skew_x = None; + } + if info.skew_y != Some(instance.transform.skew_y) { + info.skew_y = None; + } + if info.opacity != Some(instance.opacity) { + info.opacity = None; + } + + // Check shape properties + if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) { + // Compare fill colors - set to None if mixed + if let Some(current_fill) = &info.fill_color { + if *current_fill != shape.fill_color { + info.fill_color = None; + } + } + // Compare stroke colors - set to None if mixed + if let Some(current_stroke) = &info.stroke_color { + if *current_stroke != shape.stroke_color { + info.stroke_color = None; + } + } + let stroke_w = shape + .stroke_style + .as_ref() + .map(|s| s.width) + .unwrap_or(1.0); + if info.stroke_width != Some(stroke_w) { + info.stroke_width = None; + } + } + } + } + } + } + } + } + + info + } + + /// Render tool-specific options section + fn render_tool_section(&mut self, ui: &mut Ui, shared: &mut SharedPaneState) { + let tool = *shared.selected_tool; + + // Only show tool options for tools that have options + let has_options = matches!( + tool, + Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::PaintBucket | Tool::Polygon | Tool::Line + ); + + if !has_options { + return; + } + + egui::CollapsingHeader::new("Tool Options") + .default_open(self.tool_section_open) + .show(ui, |ui| { + self.tool_section_open = true; + ui.add_space(4.0); + + match tool { + Tool::Draw => { + // Stroke width + ui.horizontal(|ui| { + ui.label("Stroke Width:"); + ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0)); + }); + + // Simplify mode + ui.horizontal(|ui| { + ui.label("Simplify:"); + egui::ComboBox::from_id_salt("draw_simplify") + .selected_text(match shared.draw_simplify_mode { + SimplifyMode::Corners => "Corners", + SimplifyMode::Smooth => "Smooth", + SimplifyMode::Verbatim => "Verbatim", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + shared.draw_simplify_mode, + SimplifyMode::Corners, + "Corners", + ); + ui.selectable_value( + shared.draw_simplify_mode, + SimplifyMode::Smooth, + "Smooth", + ); + ui.selectable_value( + shared.draw_simplify_mode, + SimplifyMode::Verbatim, + "Verbatim", + ); + }); + }); + + // Fill shape toggle + ui.checkbox(shared.fill_enabled, "Fill Shape"); + } + + Tool::Rectangle | Tool::Ellipse => { + // Stroke width + ui.horizontal(|ui| { + ui.label("Stroke Width:"); + ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0)); + }); + + // Fill shape toggle + ui.checkbox(shared.fill_enabled, "Fill Shape"); + } + + Tool::PaintBucket => { + // Gap tolerance + ui.horizontal(|ui| { + ui.label("Gap Tolerance:"); + ui.add( + DragValue::new(shared.paint_bucket_gap_tolerance) + .speed(0.1) + .range(0.0..=50.0), + ); + }); + } + + Tool::Polygon => { + // Number of sides + ui.horizontal(|ui| { + ui.label("Sides:"); + let mut sides = *shared.polygon_sides as i32; + if ui.add(DragValue::new(&mut sides).range(3..=20)).changed() { + *shared.polygon_sides = sides.max(3) as u32; + } + }); + + // Stroke width + ui.horizontal(|ui| { + ui.label("Stroke Width:"); + ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0)); + }); + + // Fill shape toggle + ui.checkbox(shared.fill_enabled, "Fill Shape"); + } + + Tool::Line => { + // Stroke width + ui.horizontal(|ui| { + ui.label("Stroke Width:"); + ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0)); + }); + } + + _ => {} + } + + ui.add_space(4.0); + }); + } + + /// Render transform properties section + fn render_transform_section( + &mut self, + ui: &mut Ui, + shared: &mut SharedPaneState, + info: &SelectionInfo, + ) { + egui::CollapsingHeader::new("Transform") + .default_open(self.transform_section_open) + .show(ui, |ui| { + self.transform_section_open = true; + ui.add_space(4.0); + + let layer_id = match info.layer_id { + Some(id) => id, + None => return, + }; + + // Position X + self.render_transform_field( + ui, + "X:", + info.x, + 1.0, + f64::NEG_INFINITY..=f64::INFINITY, + |value| InstancePropertyChange::X(value), + layer_id, + &info.instance_ids, + shared, + ); + + // Position Y + self.render_transform_field( + ui, + "Y:", + info.y, + 1.0, + f64::NEG_INFINITY..=f64::INFINITY, + |value| InstancePropertyChange::Y(value), + layer_id, + &info.instance_ids, + shared, + ); + + ui.add_space(4.0); + + // Rotation + self.render_transform_field( + ui, + "Rotation:", + info.rotation, + 1.0, + -360.0..=360.0, + |value| InstancePropertyChange::Rotation(value), + layer_id, + &info.instance_ids, + shared, + ); + + ui.add_space(4.0); + + // Scale X + self.render_transform_field( + ui, + "Scale X:", + info.scale_x, + 0.01, + 0.01..=100.0, + |value| InstancePropertyChange::ScaleX(value), + layer_id, + &info.instance_ids, + shared, + ); + + // Scale Y + self.render_transform_field( + ui, + "Scale Y:", + info.scale_y, + 0.01, + 0.01..=100.0, + |value| InstancePropertyChange::ScaleY(value), + layer_id, + &info.instance_ids, + shared, + ); + + ui.add_space(4.0); + + // Skew X + self.render_transform_field( + ui, + "Skew X:", + info.skew_x, + 1.0, + -89.0..=89.0, + |value| InstancePropertyChange::SkewX(value), + layer_id, + &info.instance_ids, + shared, + ); + + // Skew Y + self.render_transform_field( + ui, + "Skew Y:", + info.skew_y, + 1.0, + -89.0..=89.0, + |value| InstancePropertyChange::SkewY(value), + layer_id, + &info.instance_ids, + shared, + ); + + ui.add_space(4.0); + + // Opacity + self.render_transform_field( + ui, + "Opacity:", + info.opacity, + 0.01, + 0.0..=1.0, + |value| InstancePropertyChange::Opacity(value), + layer_id, + &info.instance_ids, + shared, + ); + + ui.add_space(4.0); + }); + } + + /// Render a single transform property field with drag-to-adjust + fn render_transform_field( + &self, + ui: &mut Ui, + label: &str, + value: Option, + speed: f64, + range: std::ops::RangeInclusive, + make_change: F, + layer_id: Uuid, + instance_ids: &[Uuid], + shared: &mut SharedPaneState, + ) where + F: Fn(f64) -> InstancePropertyChange, + { + ui.horizontal(|ui| { + // Label with drag sense for drag-to-adjust + let label_response = ui.add(egui::Label::new(label).sense(Sense::drag())); + + match value { + Some(mut v) => { + // Handle drag on label + if label_response.dragged() { + let delta = label_response.drag_delta().x as f64 * speed; + v = (v + delta).clamp(*range.start(), *range.end()); + + // Create action for each selected instance + for instance_id in instance_ids { + let action = SetInstancePropertiesAction::new( + layer_id, + *instance_id, + make_change(v), + ); + shared.pending_actions.push(Box::new(action)); + } + } + + // DragValue widget + let response = ui.add( + DragValue::new(&mut v) + .speed(speed) + .range(range.clone()), + ); + + if response.changed() { + // Create action for each selected instance + for instance_id in instance_ids { + let action = SetInstancePropertiesAction::new( + layer_id, + *instance_id, + make_change(v), + ); + shared.pending_actions.push(Box::new(action)); + } + } + } + None => { + // Mixed values - show placeholder + ui.label("--"); + } + } + }); + } + + /// Render shape properties section (fill/stroke) + fn render_shape_section( + &mut self, + ui: &mut Ui, + shared: &mut SharedPaneState, + info: &SelectionInfo, + ) { + egui::CollapsingHeader::new("Shape") + .default_open(self.shape_section_open) + .show(ui, |ui| { + self.shape_section_open = true; + ui.add_space(4.0); + + let layer_id = match info.layer_id { + Some(id) => id, + None => return, + }; + + // Fill color + ui.horizontal(|ui| { + ui.label("Fill:"); + match info.fill_color { + Some(Some(color)) => { + let mut egui_color = egui::Color32::from_rgba_unmultiplied( + color.r, color.g, color.b, color.a, + ); + + if ui.color_edit_button_srgba(&mut egui_color).changed() { + let new_color = Some(ShapeColor::new( + egui_color.r(), + egui_color.g(), + egui_color.b(), + egui_color.a(), + )); + + // Create action for each selected shape + for shape_id in &info.shape_ids { + let action = SetShapePropertiesAction::set_fill_color( + layer_id, + *shape_id, + new_color, + ); + shared.pending_actions.push(Box::new(action)); + } + } + } + Some(None) => { + if ui.button("Add Fill").clicked() { + // Add default black fill + let default_fill = Some(ShapeColor::rgb(0, 0, 0)); + for shape_id in &info.shape_ids { + let action = SetShapePropertiesAction::set_fill_color( + layer_id, + *shape_id, + default_fill, + ); + shared.pending_actions.push(Box::new(action)); + } + } + } + None => { + ui.label("--"); + } + } + }); + + // Stroke color + ui.horizontal(|ui| { + ui.label("Stroke:"); + match info.stroke_color { + Some(Some(color)) => { + let mut egui_color = egui::Color32::from_rgba_unmultiplied( + color.r, color.g, color.b, color.a, + ); + + if ui.color_edit_button_srgba(&mut egui_color).changed() { + let new_color = Some(ShapeColor::new( + egui_color.r(), + egui_color.g(), + egui_color.b(), + egui_color.a(), + )); + + // Create action for each selected shape + for shape_id in &info.shape_ids { + let action = SetShapePropertiesAction::set_stroke_color( + layer_id, + *shape_id, + new_color, + ); + shared.pending_actions.push(Box::new(action)); + } + } + } + Some(None) => { + if ui.button("Add Stroke").clicked() { + // Add default black stroke + let default_stroke = Some(ShapeColor::rgb(0, 0, 0)); + for shape_id in &info.shape_ids { + let action = SetShapePropertiesAction::set_stroke_color( + layer_id, + *shape_id, + default_stroke, + ); + shared.pending_actions.push(Box::new(action)); + } + } + } + None => { + ui.label("--"); + } + } + }); + + // Stroke width + ui.horizontal(|ui| { + ui.label("Stroke Width:"); + match info.stroke_width { + Some(mut width) => { + let response = ui.add( + DragValue::new(&mut width) + .speed(0.1) + .range(0.1..=100.0), + ); + + if response.changed() { + for shape_id in &info.shape_ids { + let action = SetShapePropertiesAction::set_stroke_width( + layer_id, + *shape_id, + width, + ); + shared.pending_actions.push(Box::new(action)); + } + } + } + None => { + ui.label("--"); + } + } + }); + + ui.add_space(4.0); + }); + } + + /// Render document settings section (shown when nothing is selected) + fn render_document_section(&self, ui: &mut Ui, shared: &mut SharedPaneState) { + egui::CollapsingHeader::new("Document") + .default_open(true) + .show(ui, |ui| { + ui.add_space(4.0); + + let document = shared.action_executor.document(); + + // Get current values for editing + let mut width = document.width; + let mut height = document.height; + let mut duration = document.duration; + let mut framerate = document.framerate; + let layer_count = document.root.children.len(); + + // Canvas width + ui.horizontal(|ui| { + ui.label("Width:"); + if ui + .add(DragValue::new(&mut width).speed(1.0).range(1.0..=10000.0)) + .changed() + { + let action = SetDocumentPropertiesAction::set_width(width); + shared.pending_actions.push(Box::new(action)); + } + }); + + // Canvas height + ui.horizontal(|ui| { + ui.label("Height:"); + if ui + .add(DragValue::new(&mut height).speed(1.0).range(1.0..=10000.0)) + .changed() + { + let action = SetDocumentPropertiesAction::set_height(height); + shared.pending_actions.push(Box::new(action)); + } + }); + + // Duration + ui.horizontal(|ui| { + ui.label("Duration:"); + if ui + .add( + DragValue::new(&mut duration) + .speed(0.1) + .range(0.1..=3600.0) + .suffix("s"), + ) + .changed() + { + let action = SetDocumentPropertiesAction::set_duration(duration); + shared.pending_actions.push(Box::new(action)); + } + }); + + // Framerate + ui.horizontal(|ui| { + ui.label("Framerate:"); + if ui + .add( + DragValue::new(&mut framerate) + .speed(1.0) + .range(1.0..=120.0) + .suffix(" fps"), + ) + .changed() + { + let action = SetDocumentPropertiesAction::set_framerate(framerate); + shared.pending_actions.push(Box::new(action)); + } + }); + + // Layer count (read-only) + ui.horizontal(|ui| { + ui.label("Layers:"); + ui.label(format!("{}", layer_count)); + }); + + ui.add_space(4.0); + }); } } @@ -20,23 +756,61 @@ impl PaneRenderer for InfopanelPane { ui: &mut egui::Ui, rect: egui::Rect, _path: &NodePath, - _shared: &mut SharedPaneState, + shared: &mut SharedPaneState, ) { - // Placeholder rendering + // Background ui.painter().rect_filled( rect, 0.0, - egui::Color32::from_rgb(30, 50, 40), + egui::Color32::from_rgb(30, 35, 40), ); - let text = "Info Panel\n(TODO: Implement property editor)"; - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(16.0), - egui::Color32::from_gray(150), + // Create scrollable area for content + 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)), ); + + egui::ScrollArea::vertical() + .id_salt("infopanel_scroll") + .show(&mut content_ui, |ui| { + ui.set_min_width(content_rect.width() - 16.0); + + // 1. Tool options section (always shown if tool has options) + self.render_tool_section(ui, shared); + + // 2. Gather selection info + let info = self.gather_selection_info(shared); + + // 3. Transform section (if shapes selected) + if info.shape_count > 0 { + self.render_transform_section(ui, shared, &info); + } + + // 4. Shape properties section (if shapes selected) + if info.shape_count > 0 { + self.render_shape_section(ui, shared, &info); + } + + // 5. Document settings (if nothing selected) + if info.is_empty { + self.render_document_section(ui, shared); + } + + // Show selection count at bottom + if info.shape_count > 0 { + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + ui.label(format!( + "{} object{} selected", + info.shape_count, + if info.shape_count == 1 { "" } else { "s" } + )); + } + }); } fn name(&self) -> &str { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 6c0acf4..804d59f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -113,6 +113,15 @@ pub struct SharedPaneState<'a> { pub is_playing: &'a mut bool, // Whether playback is currently active /// Asset being dragged from Asset Library (for cross-pane drag-and-drop) pub dragging_asset: &'a mut Option, + // Tool-specific options for infopanel + /// Stroke width for drawing tools (Draw, Rectangle, Ellipse, Line, Polygon) + pub stroke_width: &'a mut f64, + /// Whether to fill shapes when drawing (Rectangle, Ellipse, Polygon) + pub fill_enabled: &'a mut bool, + /// Fill gap tolerance for paint bucket tool + pub paint_bucket_gap_tolerance: &'a mut f64, + /// Number of sides for polygon tool + pub polygon_sides: &'a mut u32, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 614c27c..b08d232 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -231,13 +231,14 @@ struct VelloCallback { pan_offset: egui::Vec2, zoom: f32, instance_id: u64, - document: lightningbeam_core::document::Document, + document: std::sync::Arc, tool_state: lightningbeam_core::tool::ToolState, active_layer_id: Option, drag_delta: Option, // Delta for drag preview (world space) selection: lightningbeam_core::selection::Selection, fill_color: egui::Color32, // Current fill color for previews stroke_color: egui::Color32, // Current stroke color for previews + stroke_width: f64, // Current stroke width for previews selected_tool: lightningbeam_core::tool::Tool, // Current tool for rendering mode-specific UI eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, // Pending eyedropper sample playback_time: f64, // Current playback time for animation evaluation @@ -249,18 +250,19 @@ impl VelloCallback { pan_offset: egui::Vec2, zoom: f32, instance_id: u64, - document: lightningbeam_core::document::Document, + document: std::sync::Arc, tool_state: lightningbeam_core::tool::ToolState, active_layer_id: Option, drag_delta: Option, selection: lightningbeam_core::selection::Selection, fill_color: egui::Color32, stroke_color: egui::Color32, + stroke_width: f64, selected_tool: lightningbeam_core::tool::Tool, eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, playback_time: f64, ) -> Self { - Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, selected_tool, eyedropper_request, playback_time } + Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, stroke_width, selected_tool, eyedropper_request, playback_time } } } @@ -331,14 +333,44 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Render each object at its preview position (original + delta) for (object_id, original_pos) in original_positions { // Try shape instance first - if let Some(_object) = vector_layer.get_object(object_id) { - if let Some(shape) = vector_layer.get_shape(&_object.shape_id) { + if let Some(object) = vector_layer.get_object(object_id) { + if let Some(shape) = vector_layer.get_shape(&object.shape_id) { // New position = original + delta let new_x = original_pos.x + delta.x; let new_y = original_pos.y + delta.y; - // Build transform for preview position - let object_transform = Affine::translate((new_x, new_y)); + // Build skew transform around shape center (matching renderer.rs) + let path = shape.path(); + let skew_transform = if object.transform.skew_x != 0.0 || object.transform.skew_y != 0.0 { + let bbox = path.bounding_box(); + let center_x = (bbox.x0 + bbox.x1) / 2.0; + let center_y = (bbox.y0 + bbox.y1) / 2.0; + + let skew_x_affine = if object.transform.skew_x != 0.0 { + Affine::skew(object.transform.skew_x.to_radians().tan(), 0.0) + } else { + Affine::IDENTITY + }; + + let skew_y_affine = if object.transform.skew_y != 0.0 { + Affine::skew(0.0, object.transform.skew_y.to_radians().tan()) + } else { + Affine::IDENTITY + }; + + Affine::translate((center_x, center_y)) + * skew_x_affine + * skew_y_affine + * Affine::translate((-center_x, -center_y)) + } else { + Affine::IDENTITY + }; + + // Build full transform: translate * rotate * scale * skew + let object_transform = Affine::translate((new_x, new_y)) + * Affine::rotate(object.transform.rotation.to_radians()) + * Affine::scale_non_uniform(object.transform.scale_x, object.transform.scale_y) + * skew_transform; let combined_transform = camera_transform * object_transform; // Render shape with semi-transparent fill (light blue, 40% opacity) @@ -348,7 +380,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { combined_transform, &Brush::Solid(alpha_color), None, - shape.path(), + path, ); } } @@ -783,8 +815,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { preview_path.line_to(*point); } - // Draw the preview path with stroke - let stroke_width = (2.0 / self.zoom.max(0.5) as f64).max(1.0); + // Draw the preview path with stroke using configured stroke width let stroke_color = Color::from_rgb8( self.stroke_color.r(), self.stroke_color.g(), @@ -792,7 +823,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { ); scene.stroke( - &Stroke::new(stroke_width), + &Stroke::new(self.stroke_width), camera_transform, stroke_color, None, @@ -1636,8 +1667,8 @@ impl StagePane { // Mouse up: create the rectangle shape if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingRectangle { .. })) { if let ToolState::CreatingRectangle { start_point, current_point, centered, constrain_square } = shared.tool_state.clone() { - // Calculate rectangle bounds based on mode - let (width, height, position) = if centered { + // Calculate rectangle bounds and center position based on mode + let (width, height, center) = if centered { // Centered mode: start_point is center let dx = current_point.x - start_point.x; let dy = current_point.y - start_point.y; @@ -1649,8 +1680,8 @@ impl StagePane { (dx.abs() * 2.0, dy.abs() * 2.0) }; - let pos = Point::new(start_point.x - w / 2.0, start_point.y - h / 2.0); - (w, h, pos) + // start_point is already the center + (w, h, start_point) } else { // Corner mode: start_point is corner let mut min_x = start_point.x.min(current_point.x); @@ -1676,21 +1707,35 @@ impl StagePane { } } - (max_x - min_x, max_y - min_y, Point::new(min_x, min_y)) + // Return width, height, and center position + let center_x = (min_x + max_x) / 2.0; + let center_y = (min_y + max_y) / 2.0; + (max_x - min_x, max_y - min_y, Point::new(center_x, center_y)) }; // Only create shape if rectangle has non-zero size if width > 1.0 && height > 1.0 { - use lightningbeam_core::shape::{Shape, ShapeColor}; + use lightningbeam_core::shape::{Shape, ShapeColor, StrokeStyle}; use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::actions::AddShapeAction; - // Create shape with rectangle path (built from lines) + // Create shape with rectangle path centered at origin let path = Self::create_rectangle_path(width, height); - let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); + let mut shape = Shape::new(path); - // Create object at the calculated position - let object = ShapeInstance::new(shape.id).with_position(position.x, position.y); + // Apply fill if enabled + if *shared.fill_enabled { + shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color)); + } + + // Apply stroke with configured width + shape = shape.with_stroke( + ShapeColor::from_egui(*shared.stroke_color), + StrokeStyle { width: *shared.stroke_width, ..Default::default() } + ); + + // Create object at the center position + let object = ShapeInstance::new(shape.id).with_position(center.x, center.y); // Create and execute action immediately let action = AddShapeAction::new(active_layer_id, shape, object); @@ -1798,13 +1843,24 @@ impl StagePane { // Only create shape if ellipse has non-zero size if rx > 1.0 && ry > 1.0 { - use lightningbeam_core::shape::{Shape, ShapeColor}; + use lightningbeam_core::shape::{Shape, ShapeColor, StrokeStyle}; use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::actions::AddShapeAction; // Create shape with ellipse path (built from bezier curves) let path = Self::create_ellipse_path(rx, ry); - let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); + let mut shape = Shape::new(path); + + // Apply fill if enabled + if *shared.fill_enabled { + shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color)); + } + + // Apply stroke with configured width + shape = shape.with_stroke( + ShapeColor::from_egui(*shared.stroke_color), + StrokeStyle { width: *shared.stroke_width, ..Default::default() } + ); // Create object at the calculated position let object = ShapeInstance::new(shape.id).with_position(position.x, position.y); @@ -1883,7 +1939,7 @@ impl StagePane { use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::actions::AddShapeAction; - // Create shape with line path + // Create shape with line path centered at origin let path = Self::create_line_path(dx, dy); // Lines should have stroke by default, not fill @@ -1891,13 +1947,15 @@ impl StagePane { .with_stroke( ShapeColor::from_egui(*shared.stroke_color), StrokeStyle { - width: 2.0, + width: *shared.stroke_width, ..Default::default() } ); - // Create object at the start point - let object = ShapeInstance::new(shape.id).with_position(start_point.x, start_point.y); + // Create object at the center of the line + let center_x = (start_point.x + current_point.x) / 2.0; + let center_y = (start_point.y + current_point.y) / 2.0; + let object = ShapeInstance::new(shape.id).with_position(center_x, center_y); // Create and execute action immediately let action = AddShapeAction::new(active_layer_id, shape, object); @@ -1977,7 +2035,15 @@ impl StagePane { // Create shape with polygon path let path = Self::create_polygon_path(num_sides, radius); - let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); + use lightningbeam_core::shape::StrokeStyle; + let mut shape = Shape::new(path); + if *shared.fill_enabled { + shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color)); + } + shape = shape.with_stroke( + ShapeColor::from_egui(*shared.stroke_color), + StrokeStyle { width: *shared.stroke_width, ..Default::default() } + ); // Create object at the center point let object = ShapeInstance::new(shape.id).with_position(center.x, center.y); @@ -2006,23 +2072,26 @@ impl StagePane { } } - /// Create a rectangle path from lines (easier for curve editing later) + /// Create a rectangle path centered at origin (easier for curve editing later) fn create_rectangle_path(width: f64, height: f64) -> vello::kurbo::BezPath { use vello::kurbo::{BezPath, Point}; + let half_w = width / 2.0; + let half_h = height / 2.0; + let mut path = BezPath::new(); - // Start at top-left - path.move_to(Point::new(0.0, 0.0)); + // Start at top-left (centered at origin) + path.move_to(Point::new(-half_w, -half_h)); // Top-right - path.line_to(Point::new(width, 0.0)); + path.line_to(Point::new(half_w, -half_h)); // Bottom-right - path.line_to(Point::new(width, height)); + path.line_to(Point::new(half_w, half_h)); // Bottom-left - path.line_to(Point::new(0.0, height)); + path.line_to(Point::new(-half_w, half_h)); // Close path (back to top-left) path.close_path(); @@ -2080,17 +2149,18 @@ impl StagePane { path } - /// Create a line path from start to end point + /// Create a line path centered at origin fn create_line_path(dx: f64, dy: f64) -> vello::kurbo::BezPath { use vello::kurbo::{BezPath, Point}; let mut path = BezPath::new(); - // Start at origin (object position will be the start point) - path.move_to(Point::new(0.0, 0.0)); + // Line goes from -half to +half so it's centered at origin + let half_dx = dx / 2.0; + let half_dy = dy / 2.0; - // Line to end point - path.line_to(Point::new(dx, dy)); + path.move_to(Point::new(-half_dx, -half_dy)); + path.line_to(Point::new(half_dx, half_dy)); path } @@ -2238,26 +2308,29 @@ impl StagePane { // Only create shape if path is not empty if !path.is_empty() { - // Calculate bounding box to position the object + // Calculate bounding box center for object position let bbox = path.bounding_box(); - let position = Point::new(bbox.x0, bbox.y0); + let center_x = (bbox.x0 + bbox.x1) / 2.0; + let center_y = (bbox.y0 + bbox.y1) / 2.0; - // Translate path to be relative to position (0,0 at top-left of bbox) + // Translate path so its center is at origin (0,0) use vello::kurbo::Affine; - let transform = Affine::translate((-bbox.x0, -bbox.y0)); + let transform = Affine::translate((-center_x, -center_y)); let translated_path = transform * path; - // Create shape with both fill and stroke + // Create shape with fill (if enabled) and stroke use lightningbeam_core::shape::StrokeStyle; - let shape = Shape::new(translated_path) - .with_fill(ShapeColor::from_egui(*shared.fill_color)) - .with_stroke( - ShapeColor::from_egui(*shared.stroke_color), - StrokeStyle::default(), - ); + let mut shape = Shape::new(translated_path); + if *shared.fill_enabled { + shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color)); + } + shape = shape.with_stroke( + ShapeColor::from_egui(*shared.stroke_color), + StrokeStyle { width: *shared.stroke_width, ..Default::default() } + ); - // Create object at the calculated position - let object = ShapeInstance::new(shape.id).with_position(position.x, position.y); + // Create object at the center position + let object = ShapeInstance::new(shape.id).with_position(center_x, center_y); // Create and execute action immediately let action = AddShapeAction::new(active_layer_id, shape, object); @@ -4257,18 +4330,20 @@ impl PaneRenderer for StagePane { }; // Use egui's custom painting callback for Vello + // document_arc() returns Arc - cheap pointer copy, not deep clone let callback = VelloCallback::new( rect, self.pan_offset, self.zoom, self.instance_id, - shared.action_executor.document().clone(), + shared.action_executor.document_arc(), shared.tool_state.clone(), *shared.active_layer_id, drag_delta, shared.selection.clone(), *shared.fill_color, *shared.stroke_color, + *shared.stroke_width, *shared.selected_tool, self.pending_eyedropper_sample, *shared.playback_time,