diff --git a/daw-backend/src/audio/clip.rs b/daw-backend/src/audio/clip.rs index 6f487a2..6a797d0 100644 --- a/daw-backend/src/audio/clip.rs +++ b/daw-backend/src/audio/clip.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use serde::{Serialize, Deserialize}; /// Audio clip instance ID type @@ -35,6 +36,13 @@ pub struct AudioClipInstance { /// Clip-level gain pub gain: f32, + + /// Per-instance read-ahead buffer for compressed audio streaming. + /// Each clip instance gets its own buffer so multiple instances of the + /// same file (on different tracks or at different positions) don't fight + /// over a single target_frame. + #[serde(skip)] + pub read_ahead: Option>, } /// Type alias for backwards compatibility @@ -58,6 +66,7 @@ impl AudioClipInstance { external_start, external_duration, gain: 1.0, + read_ahead: None, } } @@ -78,6 +87,7 @@ impl AudioClipInstance { external_start: start_time, external_duration: duration, gain: 1.0, + read_ahead: None, } } diff --git a/daw-backend/src/audio/disk_reader.rs b/daw-backend/src/audio/disk_reader.rs index 5c264f7..4a21c07 100644 --- a/daw-backend/src/audio/disk_reader.rs +++ b/daw-backend/src/audio/disk_reader.rs @@ -166,11 +166,27 @@ impl ReadAheadBuffer { /// Update the target frame — the file-local frame the audio callback /// is currently reading from. Called by `render_from_file` (consumer). + /// Each clip instance has its own buffer, so a plain store is sufficient. #[inline] pub fn set_target_frame(&self, frame: u64) { self.target_frame.store(frame, Ordering::Relaxed); } + /// Reset the target frame to MAX before a new render cycle. + /// If no clip calls `set_target_frame` this cycle, `has_active_target()` + /// returns false, telling the disk reader to skip this buffer. + #[inline] + pub fn reset_target_frame(&self) { + self.target_frame.store(u64::MAX, Ordering::Relaxed); + } + + /// Force-set the target frame to an exact value. + /// Used by the disk reader's seek command where we need an absolute position. + #[inline] + pub fn force_target_frame(&self, frame: u64) { + self.target_frame.store(frame, Ordering::Relaxed); + } + /// Get the target frame set by the audio callback. /// Called by the disk reader thread (producer). #[inline] @@ -178,6 +194,12 @@ impl ReadAheadBuffer { self.target_frame.load(Ordering::Relaxed) } + /// Check if any clip set a target this cycle (vs still at reset value). + #[inline] + pub fn has_active_target(&self) -> bool { + self.target_frame.load(Ordering::Relaxed) != u64::MAX + } + /// Reset the buffer to start at `new_start` with zero valid frames. /// Called by the **disk reader thread** (producer) after a seek. pub fn reset(&self, new_start: u64) { @@ -413,14 +435,14 @@ impl CompressedReader { /// Commands sent from the engine to the disk reader thread. pub enum DiskReaderCommand { - /// Start streaming a compressed file. + /// Start streaming a compressed file for a clip instance. ActivateFile { - pool_index: usize, + reader_id: u64, path: PathBuf, buffer: Arc, }, - /// Stop streaming a file. - DeactivateFile { pool_index: usize }, + /// Stop streaming for a clip instance. + DeactivateFile { reader_id: u64 }, /// The playhead has jumped — refill buffers from the new position. Seek { frame: u64 }, /// Shut down the disk reader thread. @@ -491,7 +513,7 @@ impl DiskReader { mut command_rx: rtrb::Consumer, running: Arc, ) { - let mut active_files: HashMap)> = + let mut active_files: HashMap)> = HashMap::new(); let mut decode_buf = Vec::with_capacity(8192); @@ -500,14 +522,14 @@ impl DiskReader { while let Ok(cmd) = command_rx.pop() { match cmd { DiskReaderCommand::ActivateFile { - pool_index, + reader_id, path, buffer, } => match CompressedReader::open(&path) { Ok(reader) => { - eprintln!("[DiskReader] Activated pool={}, ch={}, sr={}, path={:?}", - pool_index, reader.channels, reader.sample_rate, path); - active_files.insert(pool_index, (reader, buffer)); + eprintln!("[DiskReader] Activated reader={}, ch={}, sr={}, path={:?}", + reader_id, reader.channels, reader.sample_rate, path); + active_files.insert(reader_id, (reader, buffer)); } Err(e) => { eprintln!( @@ -516,12 +538,12 @@ impl DiskReader { ); } }, - DiskReaderCommand::DeactivateFile { pool_index } => { - active_files.remove(&pool_index); + DiskReaderCommand::DeactivateFile { reader_id } => { + active_files.remove(&reader_id); } DiskReaderCommand::Seek { frame } => { for (_, (reader, buffer)) in active_files.iter_mut() { - buffer.set_target_frame(frame); + buffer.force_target_frame(frame); buffer.reset(frame); if let Err(e) = reader.seek(frame) { eprintln!("[DiskReader] Seek error: {}", e); @@ -534,11 +556,15 @@ impl DiskReader { } } - // Fill each active file's buffer ahead of its target frame. - // Each file's target_frame is set by the audio callback in - // render_from_file, giving the file-local frame being read. - // This is independent of the global engine playhead. - for (_pool_index, (reader, buffer)) in active_files.iter_mut() { + // Fill each active reader's buffer ahead of its target frame. + // Each clip instance has its own buffer and target_frame, set by + // render_from_file during the audio callback. + for (_reader_id, (reader, buffer)) in active_files.iter_mut() { + // Skip files where no clip is currently playing + if !buffer.has_active_target() { + continue; + } + let target = buffer.target_frame(); let buf_start = buffer.start_frame(); let buf_valid = buffer.valid_frames_count(); @@ -578,8 +604,8 @@ impl DiskReader { let was_empty = buffer.valid_frames_count() == 0; buffer.write_samples(&decode_buf, frames); if was_empty { - eprintln!("[DiskReader] pool={}: first fill, {} frames, buf_start={}, valid={}", - _pool_index, frames, buffer.start_frame(), buffer.valid_frames_count()); + eprintln!("[DiskReader] reader={}: first fill, {} frames, buf_start={}, valid={}", + _reader_id, frames, buffer.start_frame(), buffer.valid_frames_count()); } } Err(e) => { diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 2f123b1..f8666bc 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -313,6 +313,9 @@ impl Engine { // Convert playhead from frames to seconds for timeline-based rendering let playhead_seconds = self.playhead as f64 / self.sample_rate as f64; + // Reset per-clip read-ahead targets before rendering. + self.project.reset_read_ahead_targets(); + // Render the entire project hierarchy into the mix buffer // Note: We need to use a raw pointer to avoid borrow checker issues // The midi_clip_pool is part of project, so we extract a reference before mutable borrow @@ -798,6 +801,12 @@ impl Engine { let _ = self.project.remove_midi_clip(track_id, instance_id); } Command::RemoveAudioClip(track_id, instance_id) => { + // Deactivate the per-clip disk reader before removing + if let Some(ref mut dr) = self.disk_reader { + dr.send(crate::audio::disk_reader::DiskReaderCommand::DeactivateFile { + reader_id: instance_id as u64, + }); + } // Remove an audio clip instance from a track (for undo/redo support) let _ = self.project.remove_audio_clip(track_id, instance_id); } @@ -1754,7 +1763,7 @@ impl Engine { (metadata.duration * metadata.sample_rate as f64).ceil() as u64 }); - let mut audio_file = crate::audio::pool::AudioFile::from_compressed( + let audio_file = crate::audio::pool::AudioFile::from_compressed( path.to_path_buf(), metadata.channels, metadata.sample_rate, @@ -1762,25 +1771,11 @@ impl Engine { ext, ); - let buffer = crate::audio::disk_reader::DiskReader::create_buffer( - metadata.sample_rate, - metadata.channels, - ); - audio_file.read_ahead = Some(buffer.clone()); - let idx = self.audio_pool.add_file(audio_file); eprintln!("[ENGINE] Compressed: total_frames={}, pool_index={}, has_disk_reader={}", total_frames, idx, self.disk_reader.is_some()); - if let Some(ref mut dr) = self.disk_reader { - dr.send(crate::audio::disk_reader::DiskReaderCommand::ActivateFile { - pool_index: idx, - path: path.to_path_buf(), - buffer, - }); - } - // Spawn background thread to decode file progressively for waveform display let bg_tx = self.chunk_generation_tx.clone(); let bg_path = path.to_path_buf(); @@ -2138,6 +2133,28 @@ impl Engine { let instance_id = self.next_clip_id; self.next_clip_id += 1; + // For compressed files, create a per-clip read-ahead buffer + let read_ahead = if let Some(file) = self.audio_pool.get_file(pool_index) { + if matches!(file.storage, crate::audio::pool::AudioStorage::Compressed { .. }) { + let buffer = crate::audio::disk_reader::DiskReader::create_buffer( + file.sample_rate, + file.channels, + ); + if let Some(ref mut dr) = self.disk_reader { + dr.send(crate::audio::disk_reader::DiskReaderCommand::ActivateFile { + reader_id: instance_id as u64, + path: file.path.clone(), + buffer: buffer.clone(), + }); + } + Some(buffer) + } else { + None + } + } else { + None + }; + let clip = AudioClipInstance { id: instance_id, audio_pool_index: pool_index, @@ -2146,6 +2163,7 @@ impl Engine { external_start: start_time, external_duration: duration, gain: 1.0, + read_ahead, }; match self.project.add_clip(track_id, clip) { diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs index 67ce6e3..7a9465a 100644 --- a/daw-backend/src/audio/pool.rs +++ b/daw-backend/src/audio/pool.rs @@ -95,9 +95,6 @@ pub struct AudioFile { /// Original file format (mp3, ogg, wav, flac, etc.) /// Used to determine if we should preserve lossy encoding during save pub original_format: Option, - /// Read-ahead buffer for streaming playback (Compressed files). - /// When present, `render_from_file` reads from this buffer instead of `data()`. - pub read_ahead: Option>, } impl AudioFile { @@ -111,7 +108,6 @@ impl AudioFile { sample_rate, frames, original_format: None, - read_ahead: None, } } @@ -125,7 +121,6 @@ impl AudioFile { sample_rate, frames, original_format, - read_ahead: None, } } @@ -157,7 +152,6 @@ impl AudioFile { sample_rate, frames: total_frames, original_format: Some("wav".to_string()), - read_ahead: None, } } @@ -180,7 +174,6 @@ impl AudioFile { sample_rate, frames: total_frames, original_format, - read_ahead: None, } } @@ -444,6 +437,7 @@ impl AudioClipPool { /// Render audio from a file in the pool with high-quality windowed sinc interpolation /// start_time_seconds: position in the audio file to start reading from (in seconds) + /// clip_read_ahead: per-clip-instance read-ahead buffer for compressed audio streaming /// Returns the number of samples actually rendered pub fn render_from_file( &self, @@ -453,13 +447,14 @@ impl AudioClipPool { gain: f32, engine_sample_rate: u32, engine_channels: u32, + clip_read_ahead: Option<&super::disk_reader::ReadAheadBuffer>, ) -> usize { let Some(audio_file) = self.files.get(pool_index) else { return 0; }; let audio_data = audio_file.data(); - let read_ahead = audio_file.read_ahead.as_deref(); + let read_ahead = clip_read_ahead; let use_read_ahead = audio_data.is_empty(); let src_channels = audio_file.channels as usize; diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 4b21c08..68f5d74 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -484,6 +484,19 @@ impl Project { } } + /// Reset all per-clip read-ahead target frames before a new render cycle. + pub fn reset_read_ahead_targets(&self) { + for track in self.tracks.values() { + if let TrackNode::Audio(audio_track) = track { + for clip in &audio_track.clips { + if let Some(ra) = clip.read_ahead.as_deref() { + ra.reset_target_frame(); + } + } + } + } + } + /// Stop all notes on all MIDI tracks pub fn stop_all_notes(&mut self) { for track in self.tracks.values_mut() { diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index 1a46368..d7db8f9 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -917,6 +917,7 @@ impl AudioTrack { combined_gain, sample_rate, channels, + clip.read_ahead.as_deref(), ); } else { // Looping case: need to handle wrap-around at loop boundaries @@ -951,6 +952,7 @@ impl AudioTrack { combined_gain, sample_rate, channels, + clip.read_ahead.as_deref(), ); total_rendered += rendered; diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index 8d8a8c0..c75f76f 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -40,3 +40,6 @@ pathdiff = "0.2" # Audio encoding for embedded files flacenc = "0.4" # For FLAC encoding (lossless) claxon = "0.4" # For FLAC decoding + +# System clipboard +arboard = "3" diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index 1474004..a271943 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -226,12 +226,14 @@ impl Action for AddClipInstanceAction { // For sampled audio, send AddAudioClipSync query use daw_backend::command::{Query, QueryResponse}; - let duration = clip.duration; + let internal_start = self.clip_instance.trim_start; + let internal_end = self.clip_instance.trim_end.unwrap_or(clip.duration); + let effective_duration = self.clip_instance.timeline_duration + .unwrap_or(internal_end - internal_start); let start_time = self.clip_instance.timeline_start; - let offset = self.clip_instance.trim_start; let query = - Query::AddAudioClipSync(*backend_track_id, *audio_pool_index, start_time, duration, offset); + Query::AddAudioClipSync(*backend_track_id, *audio_pool_index, start_time, effective_duration, internal_start); match controller.send_query(query)? { QueryResponse::AudioClipInstanceAdded(Ok(instance_id)) => { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 6a5a0d7..bb23931 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -25,6 +25,8 @@ pub mod rename_folder; pub mod delete_folder; pub mod move_asset_to_folder; pub mod update_midi_notes; +pub mod remove_clip_instances; +pub mod remove_shapes; pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; @@ -48,3 +50,5 @@ pub use rename_folder::RenameFolderAction; pub use delete_folder::{DeleteFolderAction, DeleteStrategy}; pub use move_asset_to_folder::MoveAssetToFolderAction; pub use update_midi_notes::UpdateMidiNotesAction; +pub use remove_clip_instances::RemoveClipInstancesAction; +pub use remove_shapes::RemoveShapesAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs new file mode 100644 index 0000000..22186e5 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -0,0 +1,269 @@ +//! Remove clip instances action +//! +//! Handles removing one or more clip instances from layers (for cut/delete). + +use crate::action::{Action, BackendClipInstanceId, BackendContext}; +use crate::clip::ClipInstance; +use crate::document::Document; +use crate::layer::AnyLayer; +use std::collections::HashMap; +use uuid::Uuid; + +/// Action that removes clip instances from layers +pub struct RemoveClipInstancesAction { + /// (layer_id, instance_id) pairs to remove + removals: Vec<(Uuid, Uuid)>, + /// Saved instances for rollback (layer_id -> ClipInstance) + saved: Vec<(Uuid, ClipInstance)>, + /// Saved backend mappings for rollback (instance_id -> BackendClipInstanceId) + saved_backend_ids: HashMap, +} + +impl RemoveClipInstancesAction { + /// Create a new remove clip instances action + pub fn new(removals: Vec<(Uuid, Uuid)>) -> Self { + Self { + removals, + saved: Vec::new(), + saved_backend_ids: HashMap::new(), + } + } +} + +impl Action for RemoveClipInstancesAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + self.saved.clear(); + + for (layer_id, instance_id) in &self.removals { + let layer = document + .get_layer_mut(layer_id) + .ok_or_else(|| format!("Layer {} not found", layer_id))?; + + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(el) => &mut el.clip_instances, + }; + + // Find and remove the instance, saving it for rollback + if let Some(pos) = clip_instances.iter().position(|ci| ci.id == *instance_id) { + let removed = clip_instances.remove(pos); + self.saved.push((*layer_id, removed)); + } + } + + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + // Re-insert saved instances + for (layer_id, instance) in self.saved.drain(..).rev() { + let layer = document + .get_layer_mut(&layer_id) + .ok_or_else(|| format!("Layer {} not found", layer_id))?; + + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(el) => &mut el.clip_instances, + }; + + clip_instances.push(instance); + } + + Ok(()) + } + + fn description(&self) -> String { + let count = self.removals.len(); + if count == 1 { + "Delete clip instance".to_string() + } else { + format!("Delete {} clip instances", count) + } + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + + for (layer_id, instance_id) in &self.removals { + // Only process audio layers + let layer = match document.get_layer(layer_id) { + Some(l) => l, + None => continue, + }; + if !matches!(layer, AnyLayer::Audio(_)) { + continue; + } + + let track_id = match backend.layer_to_track_map.get(layer_id) { + Some(id) => *id, + None => continue, + }; + + // Remove from backend using stored mapping + if let Some(backend_id) = backend.clip_instance_to_backend_map.remove(instance_id) { + self.saved_backend_ids.insert(*instance_id, backend_id.clone()); + match backend_id { + BackendClipInstanceId::Midi(midi_id) => { + controller.remove_midi_clip(track_id, midi_id); + } + BackendClipInstanceId::Audio(audio_id) => { + controller.remove_audio_clip(track_id, audio_id); + } + } + } + } + + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + use crate::clip::AudioClipType; + + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + + // Re-add clips that were removed from backend + for (layer_id, instance) in &self.saved { + let layer = match document.get_layer(layer_id) { + Some(l) => l, + None => continue, + }; + if !matches!(layer, AnyLayer::Audio(_)) { + continue; + } + + let track_id = match backend.layer_to_track_map.get(layer_id) { + Some(id) => *id, + None => continue, + }; + + let clip = match document.get_audio_clip(&instance.clip_id) { + Some(c) => c, + None => continue, + }; + + match &clip.clip_type { + AudioClipType::Midi { midi_clip_id } => { + use daw_backend::command::{Query, QueryResponse}; + + let internal_start = instance.trim_start; + let internal_end = instance.trim_end.unwrap_or(clip.duration); + let external_start = instance.timeline_start; + let external_duration = instance + .timeline_duration + .unwrap_or(internal_end - internal_start); + + let midi_instance = daw_backend::MidiClipInstance::new( + 0, + *midi_clip_id, + internal_start, + internal_end, + external_start, + external_duration, + ); + + let query = Query::AddMidiClipInstanceSync(track_id, midi_instance); + if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) = + controller.send_query(query) + { + backend.clip_instance_to_backend_map.insert( + instance.id, + BackendClipInstanceId::Midi(new_id), + ); + } + } + AudioClipType::Sampled { audio_pool_index } => { + use daw_backend::command::{Query, QueryResponse}; + + let internal_start = instance.trim_start; + let internal_end = instance.trim_end.unwrap_or(clip.duration); + let effective_duration = instance.timeline_duration + .unwrap_or(internal_end - internal_start); + let start_time = instance.timeline_start; + + let query = Query::AddAudioClipSync( + track_id, + *audio_pool_index, + start_time, + effective_duration, + internal_start, + ); + if let Ok(QueryResponse::AudioClipInstanceAdded(Ok(new_id))) = + controller.send_query(query) + { + backend.clip_instance_to_backend_map.insert( + instance.id, + BackendClipInstanceId::Audio(new_id), + ); + } + } + AudioClipType::Recording => {} + } + } + + // Clear saved backend IDs + self.saved_backend_ids.clear(); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::VectorLayer; + + #[test] + fn test_remove_clip_instances() { + let mut document = Document::new("Test"); + + let clip_id = Uuid::new_v4(); + let mut vector_layer = VectorLayer::new("Layer 1"); + + let mut ci1 = ClipInstance::new(clip_id); + ci1.timeline_start = 0.0; + let id1 = ci1.id; + + let mut ci2 = ClipInstance::new(clip_id); + ci2.timeline_start = 5.0; + let id2 = ci2.id; + + vector_layer.clip_instances.push(ci1); + vector_layer.clip_instances.push(ci2); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer)); + + // Remove first clip instance + let mut action = RemoveClipInstancesAction::new(vec![(layer_id, id1)]); + action.execute(&mut document).unwrap(); + + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { + assert_eq!(vl.clip_instances.len(), 1); + assert_eq!(vl.clip_instances[0].id, id2); + } + + // Rollback + action.rollback(&mut document).unwrap(); + + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { + assert_eq!(vl.clip_instances.len(), 2); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_shapes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_shapes.rs new file mode 100644 index 0000000..47a9d81 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_shapes.rs @@ -0,0 +1,152 @@ +//! Remove shapes action +//! +//! Handles removing shapes and shape instances from a vector layer (for cut/delete). + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::object::ShapeInstance; +use crate::shape::Shape; +use uuid::Uuid; + +/// Action that removes shapes and their instances from a vector layer +pub struct RemoveShapesAction { + /// Layer ID containing the shapes + layer_id: Uuid, + /// Shape IDs to remove + shape_ids: Vec, + /// Shape instance IDs to remove + instance_ids: Vec, + /// Saved shapes for rollback + saved_shapes: Vec<(Uuid, Shape)>, + /// Saved instances for rollback + saved_instances: Vec, +} + +impl RemoveShapesAction { + /// Create a new remove shapes action + pub fn new(layer_id: Uuid, shape_ids: Vec, instance_ids: Vec) -> Self { + Self { + layer_id, + shape_ids, + instance_ids, + saved_shapes: Vec::new(), + saved_instances: Vec::new(), + } + } +} + +impl Action for RemoveShapesAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + self.saved_shapes.clear(); + self.saved_instances.clear(); + + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => return Err("Not a vector layer".to_string()), + }; + + // Remove and save shape instances + let mut remaining_instances = Vec::new(); + for inst in vector_layer.shape_instances.drain(..) { + if self.instance_ids.contains(&inst.id) { + self.saved_instances.push(inst); + } else { + remaining_instances.push(inst); + } + } + vector_layer.shape_instances = remaining_instances; + + // Remove and save shape definitions + for shape_id in &self.shape_ids { + if let Some(shape) = vector_layer.shapes.remove(shape_id) { + self.saved_shapes.push((*shape_id, shape)); + } + } + + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => return Err("Not a vector layer".to_string()), + }; + + // Restore shapes + for (id, shape) in self.saved_shapes.drain(..) { + vector_layer.shapes.insert(id, shape); + } + + // Restore instances + for inst in self.saved_instances.drain(..) { + vector_layer.shape_instances.push(inst); + } + + Ok(()) + } + + fn description(&self) -> String { + let count = self.instance_ids.len(); + if count == 1 { + "Delete shape".to_string() + } else { + format!("Delete {} shapes", count) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::VectorLayer; + use crate::object::ShapeInstance; + use crate::shape::Shape; + use vello::kurbo::BezPath; + + #[test] + fn test_remove_shapes() { + let mut document = Document::new("Test"); + + let mut vector_layer = VectorLayer::new("Layer 1"); + + // Add a shape and instance + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((100.0, 100.0)); + let shape = Shape::new(path); + let shape_id = shape.id; + let instance = ShapeInstance::new(shape_id); + let instance_id = instance.id; + + vector_layer.shapes.insert(shape_id, shape); + vector_layer.shape_instances.push(instance); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer)); + + // Remove + let mut action = RemoveShapesAction::new(layer_id, vec![shape_id], vec![instance_id]); + action.execute(&mut document).unwrap(); + + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { + assert!(vl.shapes.is_empty()); + assert!(vl.shape_instances.is_empty()); + } + + // Rollback + action.rollback(&mut document).unwrap(); + + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { + assert_eq!(vl.shapes.len(), 1); + assert_eq!(vl.shape_instances.len(), 1); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index dcfba02..103b62b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -415,16 +415,18 @@ impl Action for SplitClipInstanceAction { } // 2. Add the new (right) instance - let duration = clip.duration; + let internal_start = new_instance.trim_start; + let internal_end = new_instance.trim_end.unwrap_or(clip.duration); + let effective_duration = new_instance.timeline_duration + .unwrap_or(internal_end - internal_start); let start_time = new_instance.timeline_start; - let offset = new_instance.trim_start; let query = Query::AddAudioClipSync( *backend_track_id, *audio_pool_index, start_time, - duration, - offset, + effective_duration, + internal_start, ); match controller.send_query(query)? { diff --git a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs new file mode 100644 index 0000000..84933a2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs @@ -0,0 +1,283 @@ +//! Clipboard management for cut/copy/paste operations +//! +//! Supports multiple content types (clip instances, shapes) with +//! cross-platform clipboard integration via arboard. + +use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip}; +use crate::layer::{AudioLayerType, AnyLayer}; +use crate::object::ShapeInstance; +use crate::shape::Shape; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +/// Layer type tag for clipboard, so paste knows where clips can go +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ClipboardLayerType { + Vector, + Video, + AudioSampled, + AudioMidi, + Effect, +} + +impl ClipboardLayerType { + /// Determine the clipboard layer type from a document layer + pub fn from_layer(layer: &AnyLayer) -> Self { + match layer { + AnyLayer::Vector(_) => ClipboardLayerType::Vector, + AnyLayer::Video(_) => ClipboardLayerType::Video, + AnyLayer::Audio(al) => match al.audio_layer_type { + AudioLayerType::Sampled => ClipboardLayerType::AudioSampled, + AudioLayerType::Midi => ClipboardLayerType::AudioMidi, + }, + AnyLayer::Effect(_) => ClipboardLayerType::Effect, + } + } + + /// Check if a layer is compatible with this clipboard layer type + pub fn is_compatible(&self, layer: &AnyLayer) -> bool { + match (self, layer) { + (ClipboardLayerType::Vector, AnyLayer::Vector(_)) => true, + (ClipboardLayerType::Video, AnyLayer::Video(_)) => true, + (ClipboardLayerType::AudioSampled, AnyLayer::Audio(al)) => { + al.audio_layer_type == AudioLayerType::Sampled + } + (ClipboardLayerType::AudioMidi, AnyLayer::Audio(al)) => { + al.audio_layer_type == AudioLayerType::Midi + } + (ClipboardLayerType::Effect, AnyLayer::Effect(_)) => true, + _ => false, + } + } +} + +/// Content stored in the clipboard +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ClipboardContent { + /// Clip instances with their referenced clip definitions + ClipInstances { + /// Source layer type (for paste compatibility) + layer_type: ClipboardLayerType, + /// The clip instances (IDs will be regenerated on paste) + instances: Vec, + /// Referenced audio clip definitions + audio_clips: Vec<(Uuid, AudioClip)>, + /// Referenced video clip definitions + video_clips: Vec<(Uuid, VideoClip)>, + /// Referenced vector clip definitions + vector_clips: Vec<(Uuid, VectorClip)>, + /// Referenced image assets + image_assets: Vec<(Uuid, ImageAsset)>, + }, + /// Shapes and shape instances from a vector layer + Shapes { + /// Shape definitions (id -> shape) + shapes: Vec<(Uuid, Shape)>, + /// Shape instances referencing the shapes above + instances: Vec, + }, +} + +impl ClipboardContent { + /// Create a clone of this content with all UUIDs regenerated + /// Returns the new content and a mapping from old to new IDs + pub fn with_regenerated_ids(&self) -> (Self, HashMap) { + let mut id_map = HashMap::new(); + + match self { + ClipboardContent::ClipInstances { + layer_type, + instances, + audio_clips, + video_clips, + vector_clips, + image_assets, + } => { + // Regenerate clip definition IDs + let new_audio_clips: Vec<(Uuid, AudioClip)> = audio_clips + .iter() + .map(|(old_id, clip)| { + let new_id = Uuid::new_v4(); + id_map.insert(*old_id, new_id); + let mut new_clip = clip.clone(); + new_clip.id = new_id; + (new_id, new_clip) + }) + .collect(); + + let new_video_clips: Vec<(Uuid, VideoClip)> = video_clips + .iter() + .map(|(old_id, clip)| { + let new_id = Uuid::new_v4(); + id_map.insert(*old_id, new_id); + let mut new_clip = clip.clone(); + new_clip.id = new_id; + (new_id, new_clip) + }) + .collect(); + + let new_vector_clips: Vec<(Uuid, VectorClip)> = vector_clips + .iter() + .map(|(old_id, clip)| { + let new_id = Uuid::new_v4(); + id_map.insert(*old_id, new_id); + let mut new_clip = clip.clone(); + new_clip.id = new_id; + (new_id, new_clip) + }) + .collect(); + + let new_image_assets: Vec<(Uuid, ImageAsset)> = image_assets + .iter() + .map(|(old_id, asset)| { + let new_id = Uuid::new_v4(); + id_map.insert(*old_id, new_id); + let mut new_asset = asset.clone(); + new_asset.id = new_id; + (new_id, new_asset) + }) + .collect(); + + // Regenerate clip instance IDs and remap clip_id references + let new_instances: Vec = instances + .iter() + .map(|inst| { + let new_instance_id = Uuid::new_v4(); + id_map.insert(inst.id, new_instance_id); + let mut new_inst = inst.clone(); + new_inst.id = new_instance_id; + // Remap clip_id to new definition ID + if let Some(new_clip_id) = id_map.get(&inst.clip_id) { + new_inst.clip_id = *new_clip_id; + } + new_inst + }) + .collect(); + + ( + ClipboardContent::ClipInstances { + layer_type: layer_type.clone(), + instances: new_instances, + audio_clips: new_audio_clips, + video_clips: new_video_clips, + vector_clips: new_vector_clips, + image_assets: new_image_assets, + }, + id_map, + ) + } + ClipboardContent::Shapes { shapes, instances } => { + // Regenerate shape definition IDs + let new_shapes: Vec<(Uuid, Shape)> = shapes + .iter() + .map(|(old_id, shape)| { + let new_id = Uuid::new_v4(); + id_map.insert(*old_id, new_id); + let mut new_shape = shape.clone(); + new_shape.id = new_id; + (new_id, new_shape) + }) + .collect(); + + // Regenerate instance IDs and remap shape_id references + let new_instances: Vec = instances + .iter() + .map(|inst| { + let new_instance_id = Uuid::new_v4(); + id_map.insert(inst.id, new_instance_id); + let mut new_inst = inst.clone(); + new_inst.id = new_instance_id; + // Remap shape_id to new definition ID + if let Some(new_shape_id) = id_map.get(&inst.shape_id) { + new_inst.shape_id = *new_shape_id; + } + new_inst + }) + .collect(); + + ( + ClipboardContent::Shapes { + shapes: new_shapes, + instances: new_instances, + }, + id_map, + ) + } + } + } +} + +/// JSON prefix for clipboard text to identify Lightningbeam content +const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:"; + +/// Manages clipboard operations with internal + system clipboard +pub struct ClipboardManager { + /// Internal clipboard (preserves rich data without serialization loss) + internal: Option, + /// System clipboard handle (lazy-initialized) + system: Option, +} + +impl ClipboardManager { + /// Create a new clipboard manager + pub fn new() -> Self { + let system = arboard::Clipboard::new().ok(); + Self { + internal: None, + system, + } + } + + /// Copy content to both internal and system clipboard + pub fn copy(&mut self, content: ClipboardContent) { + // Serialize to system clipboard as JSON text + if let Some(system) = self.system.as_mut() { + if let Ok(json) = serde_json::to_string(&content) { + let clipboard_text = format!("{}{}", CLIPBOARD_PREFIX, json); + let _ = system.set_text(clipboard_text); + } + } + + // Store internally for rich access + self.internal = Some(content); + } + + /// Try to paste content + /// Returns internal clipboard if available, falls back to system clipboard JSON + pub fn paste(&mut self) -> Option { + // Try internal clipboard first + if let Some(content) = &self.internal { + return Some(content.clone()); + } + + // Fall back to system clipboard + if let Some(system) = self.system.as_mut() { + if let Ok(text) = system.get_text() { + if let Some(json) = text.strip_prefix(CLIPBOARD_PREFIX) { + if let Ok(content) = serde_json::from_str::(json) { + return Some(content); + } + } + } + } + + None + } + + /// Check if there's content available to paste + pub fn has_content(&mut self) -> bool { + if self.internal.is_some() { + return true; + } + + if let Some(system) = self.system.as_mut() { + if let Ok(text) = system.get_text() { + return text.starts_with(CLIPBOARD_PREFIX); + } + } + + false + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 9d75d20..8d07f67 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -40,3 +40,4 @@ pub mod planar_graph; pub mod file_types; pub mod file_io; pub mod export; +pub mod clipboard; diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 72d36bf..b534dfd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -682,6 +682,8 @@ struct EditorApp { recording_layer_id: Option, // Layer being recorded to (for creating clips) // Asset drag-and-drop state dragging_asset: Option, // Asset being dragged from Asset Library + // Clipboard + clipboard_manager: lightningbeam_core::clipboard::ClipboardManager, // Shader editor inter-pane communication effect_to_load: Option, // Effect ID to load into shader editor (set by asset library) // Effect thumbnail invalidation queue (persists across frames until processed) @@ -896,6 +898,7 @@ impl EditorApp { recording_start_time: 0.0, // Will be set when recording starts recording_layer_id: None, // Will be set when recording starts dragging_asset: None, // No asset being dragged initially + clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(), effect_to_load: None, // No effect to load initially effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially last_import_filter: ImportFilter::default(), // Default to "All Supported" @@ -1472,6 +1475,364 @@ impl EditorApp { } } + /// Copy the current selection to the clipboard + fn clipboard_copy_selection(&mut self) { + use lightningbeam_core::clipboard::{ClipboardContent, ClipboardLayerType}; + use lightningbeam_core::layer::AnyLayer; + + // Check what's selected: clip instances take priority, then shapes + if !self.selection.clip_instances().is_empty() { + let active_layer_id = match self.active_layer_id { + Some(id) => id, + None => return, + }; + + let document = self.action_executor.document(); + let layer = match document.get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + + let layer_type = ClipboardLayerType::from_layer(layer); + + let instances: Vec<_> = match layer { + AnyLayer::Vector(vl) => &vl.clip_instances, + AnyLayer::Audio(al) => &al.clip_instances, + AnyLayer::Video(vl) => &vl.clip_instances, + AnyLayer::Effect(el) => &el.clip_instances, + } + .iter() + .filter(|ci| self.selection.contains_clip_instance(&ci.id)) + .cloned() + .collect(); + + if instances.is_empty() { + return; + } + + // Gather referenced clip definitions + let mut audio_clips = Vec::new(); + let mut video_clips = Vec::new(); + let mut vector_clips = Vec::new(); + let image_assets = Vec::new(); + let mut seen_clip_ids = std::collections::HashSet::new(); + + for inst in &instances { + if !seen_clip_ids.insert(inst.clip_id) { + continue; + } + if let Some(clip) = document.get_audio_clip(&inst.clip_id) { + audio_clips.push((inst.clip_id, clip.clone())); + } else if let Some(clip) = document.get_video_clip(&inst.clip_id) { + video_clips.push((inst.clip_id, clip.clone())); + } else if let Some(clip) = document.get_vector_clip(&inst.clip_id) { + vector_clips.push((inst.clip_id, clip.clone())); + } + } + + // Gather image assets referenced by vector clips + // (Future: walk vector clip layers for image fill references) + + let content = ClipboardContent::ClipInstances { + layer_type, + instances, + audio_clips, + video_clips, + vector_clips, + image_assets, + }; + + self.clipboard_manager.copy(content); + } else if !self.selection.shape_instances().is_empty() { + let active_layer_id = match self.active_layer_id { + Some(id) => id, + None => return, + }; + + let document = self.action_executor.document(); + let layer = match document.get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => return, + }; + + // Gather selected shape instances and their shape definitions + let selected_instances: Vec<_> = vector_layer + .shape_instances + .iter() + .filter(|si| self.selection.contains_shape_instance(&si.id)) + .cloned() + .collect(); + + if selected_instances.is_empty() { + return; + } + + let mut shapes = Vec::new(); + let mut seen_shape_ids = std::collections::HashSet::new(); + for inst in &selected_instances { + if seen_shape_ids.insert(inst.shape_id) { + if let Some(shape) = vector_layer.shapes.get(&inst.shape_id) { + shapes.push((inst.shape_id, shape.clone())); + } + } + } + + let content = ClipboardContent::Shapes { + shapes, + instances: selected_instances, + }; + + self.clipboard_manager.copy(content); + } + } + + /// Delete the current selection (for cut and delete operations) + fn clipboard_delete_selection(&mut self) { + use lightningbeam_core::layer::AnyLayer; + + if !self.selection.clip_instances().is_empty() { + let active_layer_id = match self.active_layer_id { + Some(id) => id, + None => return, + }; + + // Build removals list + let removals: Vec<(Uuid, Uuid)> = self + .selection + .clip_instances() + .iter() + .map(|&id| (active_layer_id, id)) + .collect(); + + if removals.is_empty() { + return; + } + + let action = lightningbeam_core::actions::RemoveClipInstancesAction::new(removals); + + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + let mut backend_context = lightningbeam_core::action::BackendContext { + audio_controller: Some(&mut *controller), + layer_to_track_map: &self.layer_to_track_map, + clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + }; + if let Err(e) = self + .action_executor + .execute_with_backend(Box::new(action), &mut backend_context) + { + eprintln!("Delete clip instances failed: {}", e); + } + } else { + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Delete clip instances failed: {}", e); + } + } + + self.selection.clear_clip_instances(); + } else if !self.selection.shape_instances().is_empty() { + let active_layer_id = match self.active_layer_id { + Some(id) => id, + None => return, + }; + + let document = self.action_executor.document(); + let layer = match document.get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => return, + }; + + // Collect shape instance IDs and their shape IDs + let instance_ids: Vec = self.selection.shape_instances().to_vec(); + let mut shape_ids: Vec = Vec::new(); + let mut shape_id_set = std::collections::HashSet::new(); + + for inst in &vector_layer.shape_instances { + if instance_ids.contains(&inst.id) { + if shape_id_set.insert(inst.shape_id) { + // Only remove shape definition if no other instances reference it + let other_refs = vector_layer + .shape_instances + .iter() + .any(|si| si.shape_id == inst.shape_id && !instance_ids.contains(&si.id)); + if !other_refs { + shape_ids.push(inst.shape_id); + } + } + } + } + + let action = lightningbeam_core::actions::RemoveShapesAction::new( + active_layer_id, + shape_ids, + instance_ids, + ); + + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Delete shapes failed: {}", e); + } + + self.selection.clear_shape_instances(); + self.selection.clear_shapes(); + } + } + + /// Paste from clipboard + fn clipboard_paste(&mut self) { + use lightningbeam_core::clipboard::ClipboardContent; + use lightningbeam_core::layer::AnyLayer; + + let content = match self.clipboard_manager.paste() { + Some(c) => c, + None => return, + }; + + // Regenerate IDs for the paste + let (new_content, _id_map) = content.with_regenerated_ids(); + + match new_content { + ClipboardContent::ClipInstances { + layer_type, + mut instances, + audio_clips, + video_clips, + vector_clips, + image_assets, + } => { + let active_layer_id = match self.active_layer_id { + Some(id) => id, + None => return, + }; + + // Verify layer compatibility + { + let document = self.action_executor.document(); + let layer = match document.get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + if !layer_type.is_compatible(layer) { + eprintln!("Cannot paste: incompatible layer type"); + return; + } + } + + // Add clip definitions to document (they have new IDs from regeneration) + { + let document = self.action_executor.document_mut(); + for (_id, clip) in &audio_clips { + document.audio_clips.insert(clip.id, clip.clone()); + } + for (_id, clip) in &video_clips { + document.video_clips.insert(clip.id, clip.clone()); + } + for (_id, clip) in &vector_clips { + document.vector_clips.insert(clip.id, clip.clone()); + } + for (_id, asset) in &image_assets { + document.image_assets.insert(asset.id, asset.clone()); + } + } + + // Position instances at playhead, preserving relative offsets + if !instances.is_empty() { + let min_start = instances + .iter() + .map(|i| i.timeline_start) + .fold(f64::INFINITY, f64::min); + let offset = self.playback_time - min_start; + for inst in &mut instances { + inst.timeline_start = (inst.timeline_start + offset).max(0.0); + } + } + + // Add each instance via action (handles overlap avoidance) + let new_ids: Vec = instances.iter().map(|i| i.id).collect(); + + for instance in instances { + let action = lightningbeam_core::actions::AddClipInstanceAction::new( + active_layer_id, + instance, + ); + + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + let mut backend_context = lightningbeam_core::action::BackendContext { + audio_controller: Some(&mut *controller), + layer_to_track_map: &self.layer_to_track_map, + clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + }; + if let Err(e) = self + .action_executor + .execute_with_backend(Box::new(action), &mut backend_context) + { + eprintln!("Paste clip failed: {}", e); + } + } else { + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Paste clip failed: {}", e); + } + } + } + + // Select pasted clips + self.selection.clear_clip_instances(); + for id in new_ids { + self.selection.add_clip_instance(id); + } + } + ClipboardContent::Shapes { + shapes, + instances, + } => { + let active_layer_id = match self.active_layer_id { + Some(id) => id, + None => return, + }; + + // Add shapes and instances to the active vector layer + let document = self.action_executor.document_mut(); + let layer = match document.get_layer_mut(&active_layer_id) { + Some(l) => l, + None => return, + }; + + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => { + eprintln!("Cannot paste shapes: not a vector layer"); + return; + } + }; + + let new_instance_ids: Vec = instances.iter().map(|i| i.id).collect(); + + for (id, shape) in shapes { + vector_layer.shapes.insert(id, shape); + } + for inst in instances { + vector_layer.shape_instances.push(inst); + } + + // Select pasted shapes + self.selection.clear_shape_instances(); + for id in new_instance_ids { + self.selection.add_shape_instance(id); + } + } + } + } + /// Duplicate the selected clip instances on the active layer. /// Each duplicate is placed immediately after the original clip. fn duplicate_selected_clips(&mut self) { @@ -1870,20 +2231,17 @@ impl EditorApp { } } MenuAction::Cut => { - println!("Menu: Cut"); - // TODO: Implement cut + self.clipboard_copy_selection(); + self.clipboard_delete_selection(); } MenuAction::Copy => { - println!("Menu: Copy"); - // TODO: Implement copy + self.clipboard_copy_selection(); } MenuAction::Paste => { - println!("Menu: Paste"); - // TODO: Implement paste + self.clipboard_paste(); } MenuAction::Delete => { - println!("Menu: Delete"); - // TODO: Implement delete + self.clipboard_delete_selection(); } MenuAction::SelectAll => { println!("Menu: Select All"); @@ -4030,6 +4388,7 @@ impl eframe::App for EditorApp { effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate, target_format: self.target_format, pending_menu_actions: &mut pending_menu_actions, + clipboard_manager: &mut self.clipboard_manager, }; render_layout_node( @@ -4153,6 +4512,24 @@ impl eframe::App for EditorApp { } ctx.input(|i| { + // Handle clipboard events (Ctrl+C/X/V) — winit converts these to + // Event::Copy/Cut/Paste instead of regular key events, so + // check_shortcuts won't see them via key_pressed(). + for event in &i.events { + match event { + egui::Event::Copy => { + self.handle_menu_action(MenuAction::Copy); + } + egui::Event::Cut => { + self.handle_menu_action(MenuAction::Cut); + } + egui::Event::Paste(_) => { + self.handle_menu_action(MenuAction::Paste); + } + _ => {} + } + } + // Check menu shortcuts that use modifiers (Cmd+S, etc.) - allow even when typing // But skip shortcuts without modifiers when keyboard input is claimed (e.g., virtual piano) if let Some(action) = MenuSystem::check_shortcuts(i) { @@ -4282,6 +4659,8 @@ struct RenderContext<'a> { target_format: wgpu::TextureFormat, /// Menu actions queued by panes (e.g. context menus), processed after rendering pending_menu_actions: &'a mut Vec, + /// Clipboard manager for paste availability checks + clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager, } /// Recursively render a layout node with drag support @@ -4761,6 +5140,7 @@ fn render_pane( effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, target_format: ctx.target_format, pending_menu_actions: ctx.pending_menu_actions, + clipboard_manager: ctx.clipboard_manager, }; pane_instance.render_header(&mut header_ui, &mut shared); } @@ -4829,6 +5209,7 @@ fn render_pane( effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, target_format: ctx.target_format, pending_menu_actions: ctx.pending_menu_actions, + clipboard_manager: ctx.clipboard_manager, }; // Render pane content (header was already rendered above) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 059d0a9..32a8834 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -213,6 +213,8 @@ pub struct SharedPaneState<'a> { pub target_format: wgpu::TextureFormat, /// Menu actions queued by panes (e.g. context menu items), processed by main after rendering pub pending_menu_actions: &'a mut Vec, + /// Clipboard manager for cut/copy/paste operations + pub clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index f1fbb35..5913aba 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -56,8 +56,9 @@ pub struct TimelinePane { /// Track if a layer control widget was clicked this frame layer_control_clicked: bool, - /// Context menu state: Some((clip_instance_id, position)) when a right-click menu is open - context_menu_clip: Option<(uuid::Uuid, egui::Pos2)>, + /// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open + /// clip_id is None when right-clicking on empty timeline space + context_menu_clip: Option<(Option, egui::Pos2)>, } /// Check if a clip type can be dropped on a layer type @@ -2179,32 +2180,36 @@ impl PaneRenderer for TimelinePane { shared.audio_controller, ); - // Clip context menu: detect right-click on clips + // Context menu: detect right-click on clips or empty timeline space let mut just_opened_menu = false; let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary)); if secondary_clicked { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { - if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect) { - // Select the clip if not already selected - if !shared.selection.contains_clip_instance(&clip_id) { - shared.selection.select_only_clip_instance(clip_id); + if content_rect.contains(pos) { + if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect) { + // Right-clicked on a clip + if !shared.selection.contains_clip_instance(&clip_id) { + shared.selection.select_only_clip_instance(clip_id); + } + self.context_menu_clip = Some((Some(clip_id), pos)); + } else { + // Right-clicked on empty timeline space + self.context_menu_clip = Some((None, pos)); } - self.context_menu_clip = Some((clip_id, pos)); just_opened_menu = true; - } else { - self.context_menu_clip = None; } } } - // Render clip context menu - if let Some((_ctx_clip_id, menu_pos)) = self.context_menu_clip { + // Render context menu + if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip { + let has_clip = ctx_clip_id.is_some(); // Determine which items are enabled let playback_time = *shared.playback_time; let min_split_px = 4.0_f32; // Split: playhead must be over a selected clip, at least min_split_px from edges - let split_enabled = { + let split_enabled = has_clip && { let mut enabled = false; if let Some(layer_id) = *shared.active_layer_id { if let Some(layer) = document.get_layer(&layer_id) { @@ -2233,7 +2238,7 @@ impl PaneRenderer for TimelinePane { }; // Duplicate: check if there's room to the right of each selected clip - let duplicate_enabled = { + let duplicate_enabled = has_clip && { let mut enabled = false; if let Some(layer_id) = *shared.active_layer_id { if let Some(layer) = document.get_layer(&layer_id) { @@ -2263,6 +2268,58 @@ impl PaneRenderer for TimelinePane { enabled }; + // Paste: check if clipboard has content and there's room at playhead + let paste_enabled = { + let mut enabled = false; + if shared.clipboard_manager.has_content() { + if let Some(layer_id) = *shared.active_layer_id { + if let Some(content) = shared.clipboard_manager.paste() { + if let lightningbeam_core::clipboard::ClipboardContent::ClipInstances { + ref layer_type, + ref instances, + .. + } = content + { + if let Some(layer) = document.get_layer(&layer_id) { + if layer_type.is_compatible(layer) && !instances.is_empty() { + // Check if each pasted clip would fit at playhead + let min_start = instances + .iter() + .map(|i| i.timeline_start) + .fold(f64::INFINITY, f64::min); + let offset = *shared.playback_time - min_start; + + enabled = instances.iter().all(|ci| { + let paste_start = (ci.timeline_start + offset).max(0.0); + if let Some(dur) = document.get_clip_duration(&ci.clip_id) { + let eff = ci.effective_duration(dur); + document + .find_nearest_valid_position( + &layer_id, + paste_start, + eff, + &[], + ) + .is_some() + } else { + // Clip def not in document yet (from external paste) — allow + true + } + }); + } + } + } else { + // Shapes paste — always enabled if layer is vector + if let Some(layer) = document.get_layer(&layer_id) { + enabled = matches!(layer, AnyLayer::Vector(_)); + } + } + } + } + } + enabled + }; + let area_id = ui.id().with("clip_context_menu"); let mut item_clicked = false; let area_response = egui::Area::new(area_id) @@ -2311,16 +2368,20 @@ impl PaneRenderer for TimelinePane { item_clicked = true; } ui.separator(); - if menu_item(ui, "Cut", true) { + if menu_item(ui, "Cut", has_clip) { shared.pending_menu_actions.push(crate::menu::MenuAction::Cut); item_clicked = true; } - if menu_item(ui, "Copy", true) { + if menu_item(ui, "Copy", has_clip) { shared.pending_menu_actions.push(crate::menu::MenuAction::Copy); item_clicked = true; } + if menu_item(ui, "Paste", paste_enabled) { + shared.pending_menu_actions.push(crate::menu::MenuAction::Paste); + item_clicked = true; + } ui.separator(); - if menu_item(ui, "Delete", true) { + if menu_item(ui, "Delete", has_clip) { shared.pending_menu_actions.push(crate::menu::MenuAction::Delete); item_clicked = true; }