diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index c2812bd..12e8dcc 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -2507,6 +2507,12 @@ impl Engine { Err(e) => QueryResponse::ProjectSet(Err(format!("Failed to rebuild audio graphs: {}", e))), } } + Query::DuplicateMidiClipSync(clip_id) => { + match self.project.midi_clip_pool.duplicate_clip(clip_id) { + Some(new_id) => QueryResponse::MidiClipDuplicated(Ok(new_id)), + None => QueryResponse::MidiClipDuplicated(Err(format!("MIDI clip {} not found", clip_id))), + } + } }; // Send response back diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index e2e2441..698bd65 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -405,6 +405,8 @@ pub enum Query { GetProject, /// Set the project (replaces current project state) SetProject(Box), + /// Duplicate a MIDI clip in the pool, returning the new clip's ID + DuplicateMidiClipSync(MidiClipId), } /// Oscilloscope data from a node @@ -478,4 +480,6 @@ pub enum QueryResponse { ProjectRetrieved(Result, String>), /// Project set ProjectSet(Result<(), String>), + /// MIDI clip duplicated (returns new clip ID) + MidiClipDuplicated(Result), } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 16625b6..8f35980 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1898,6 +1898,9 @@ impl EditorApp { self.selection.add_shape(id); } } + ClipboardContent::MidiNotes { .. } => { + // MIDI notes are pasted directly in the piano roll pane, not here + } } } @@ -1912,43 +1915,98 @@ impl EditorApp { None => return, }; - let document = self.action_executor.document(); - let selection = &self.selection; + // Gather all data from document in a scoped block so the borrow is released + let (_clips_to_duplicate, midi_clip_replacements, duplicates, cache_copies) = { + let document = self.action_executor.document(); + let selection = &self.selection; - // Find selected clip instances on the active layer - let clips_to_duplicate: Vec = { - let layer = match document.get_layer(&active_layer_id) { - Some(l) => l, - None => return, + // Find selected clip instances on the active layer + let clips_to_duplicate: Vec = { + let layer = match document.get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + let instances = 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, + }; + instances.iter() + .filter(|ci| selection.contains_clip_instance(&ci.id)) + .cloned() + .collect() }; - let instances = 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, - }; - instances.iter() - .filter(|ci| selection.contains_clip_instance(&ci.id)) - .cloned() - .collect() + + if clips_to_duplicate.is_empty() { + return; + } + + // For MIDI clips, duplicate the backend clip to get independent note data. + let mut midi_clip_replacements: std::collections::HashMap = std::collections::HashMap::new(); + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for original in &clips_to_duplicate { + if let Some(clip) = document.audio_clips.get(&original.clip_id) { + if let lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } = clip.clip_type { + let query = daw_backend::command::types::Query::DuplicateMidiClipSync(midi_clip_id); + if let Ok(daw_backend::command::types::QueryResponse::MidiClipDuplicated(Ok(new_midi_id))) = controller.send_query(query) { + let new_clip_def_id = uuid::Uuid::new_v4(); + let mut new_clip = clip.clone(); + new_clip.id = new_clip_def_id; + new_clip.clip_type = lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id: new_midi_id }; + new_clip.name = format!("{} (copy)", clip.name); + midi_clip_replacements.insert(original.clip_id, (new_clip_def_id, new_clip)); + } + } + } + } + } + + // Build duplicate instances + let duplicates: Vec = clips_to_duplicate.iter().map(|original| { + let mut duplicate = original.clone(); + duplicate.id = uuid::Uuid::new_v4(); + let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0); + let effective_duration = original.effective_duration(clip_duration); + duplicate.timeline_start = original.timeline_start + effective_duration; + if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { + duplicate.clip_id = *new_clip_def_id; + } + duplicate + }).collect(); + + // Collect old->new MIDI clip ID pairs for cache copying + let cache_copies: Vec<(u32, u32)> = clips_to_duplicate.iter() + .filter_map(|original| { + let (_, new_clip) = midi_clip_replacements.get(&original.clip_id)?; + let old_midi_id = document.audio_clips.get(&original.clip_id)?.midi_clip_id()?; + if let lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id: new_midi_id } = new_clip.clip_type { + Some((old_midi_id, new_midi_id)) + } else { + None + } + }) + .collect(); + + (clips_to_duplicate, midi_clip_replacements, duplicates, cache_copies) }; - - if clips_to_duplicate.is_empty() { - return; - } - - // Collect all duplicate instances upfront to release the document borrow - let duplicates: Vec = clips_to_duplicate.iter().map(|original| { - let mut duplicate = original.clone(); - duplicate.id = uuid::Uuid::new_v4(); - let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0); - let effective_duration = original.effective_duration(clip_duration); - duplicate.timeline_start = original.timeline_start + effective_duration; - duplicate - }).collect(); + // document borrow is now released let new_ids: Vec = duplicates.iter().map(|d| d.id).collect(); + // Copy MIDI event cache entries + for (old_midi_id, new_midi_id) in cache_copies { + if let Some(events) = self.midi_event_cache.get(&old_midi_id).cloned() { + self.midi_event_cache.insert(new_midi_id, events); + } + } + + // Register the new MIDI clip definitions in the document + for (_, (new_clip_def_id, new_clip)) in &midi_clip_replacements { + self.action_executor.document_mut().audio_clips.insert(*new_clip_def_id, new_clip.clone()); + } + for duplicate in duplicates { let action = AddClipInstanceAction::new(active_layer_id, duplicate); @@ -4632,6 +4690,7 @@ impl eframe::App for EditorApp { // Main pane area (editor mode) let mut layout_action: Option = None; + let mut clipboard_consumed = false; egui::CentralPanel::default().show(ctx, |ui| { let available_rect = ui.available_rect_before_wrap(); @@ -4753,6 +4812,7 @@ impl eframe::App for EditorApp { region_selection: &mut self.region_selection, region_select_mode: &mut self.region_select_mode, pending_graph_loads: &self.pending_graph_loads, + clipboard_consumed: &mut clipboard_consumed, }; render_layout_node( @@ -4908,18 +4968,21 @@ impl eframe::App for EditorApp { // 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); + // Skip if a pane (e.g. piano roll) already handled the clipboard event. + if !clipboard_consumed { + 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); + } + _ => {} } - egui::Event::Cut => { - self.handle_menu_action(MenuAction::Cut); - } - egui::Event::Paste(_) => { - self.handle_menu_action(MenuAction::Paste); - } - _ => {} } } @@ -5089,6 +5152,8 @@ struct RenderContext<'a> { region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode, /// Counter for in-flight graph preset loads (keeps repaint loop alive) pending_graph_loads: &'a std::sync::Arc, + /// Set by panes when they handle Ctrl+C/X/V internally + clipboard_consumed: &'a mut bool, } /// Recursively render a layout node with drag support @@ -5576,6 +5641,7 @@ fn render_pane( region_selection: ctx.region_selection, region_select_mode: ctx.region_select_mode, pending_graph_loads: ctx.pending_graph_loads, + clipboard_consumed: ctx.clipboard_consumed, editing_clip_id: ctx.editing_clip_id, editing_instance_id: ctx.editing_instance_id, editing_parent_layer_id: ctx.editing_parent_layer_id, @@ -5657,6 +5723,7 @@ fn render_pane( region_selection: ctx.region_selection, region_select_mode: ctx.region_select_mode, pending_graph_loads: ctx.pending_graph_loads, + clipboard_consumed: ctx.clipboard_consumed, editing_clip_id: ctx.editing_clip_id, editing_instance_id: ctx.editing_instance_id, editing_parent_layer_id: ctx.editing_parent_layer_id, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 127539d..1ab3c81 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -241,6 +241,9 @@ pub struct SharedPaneState<'a> { /// GraphLoadPreset command so the repaint loop stays alive until the /// audio thread sends GraphPresetLoaded back pub pending_graph_loads: &'a std::sync::Arc, + /// Set by panes (e.g. piano roll) when they handle Ctrl+C/X/V internally, + /// so main.rs skips its own clipboard handling for the current frame + pub clipboard_consumed: &'a mut bool, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 68a7c59..5012de9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -252,7 +252,7 @@ impl PianoRollPane { for instance in &audio_layer.clip_instances { if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { - let duration = instance.timeline_duration.unwrap_or(clip.duration); + let duration = instance.effective_duration(clip.duration); clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id)); } } @@ -325,7 +325,7 @@ impl PianoRollPane { // Render notes if let Some(events) = shared.midi_event_cache.get(&midi_clip_id) { let resolved = Self::resolve_notes(events); - self.render_notes(&grid_painter, grid_rect, &resolved, timeline_start, trim_start, opacity, is_selected); + self.render_notes(&grid_painter, grid_rect, &resolved, timeline_start, trim_start, duration, opacity, is_selected); } } @@ -333,7 +333,8 @@ impl PianoRollPane { if let Some(ref temp) = self.creating_note { if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) { let timeline_start = selected_clip.1; - let x = self.time_to_x(timeline_start + temp.start_time, grid_rect); + let trim_start = selected_clip.2; + let x = self.time_to_x(timeline_start + (temp.start_time - trim_start), grid_rect); let y = self.note_to_y(temp.note, grid_rect); let w = (temp.duration as f32 * self.pixels_per_second).max(2.0); let note_rect = Rect::from_min_size(pos2(x, y), vec2(w, self.note_height - 2.0)); @@ -488,12 +489,21 @@ impl PianoRollPane { grid_rect: Rect, notes: &[ResolvedNote], clip_timeline_start: f64, - _trim_start: f64, + trim_start: f64, + clip_duration: f64, opacity: f32, is_selected_clip: bool, ) { for (i, note) in notes.iter().enumerate() { - let global_time = clip_timeline_start + note.start_time; + // Skip notes entirely outside the visible trim window + if note.start_time + note.duration <= trim_start { + continue; + } + if note.start_time >= trim_start + clip_duration { + continue; + } + + let global_time = clip_timeline_start + (note.start_time - trim_start); // Apply drag offset for selected notes during move let (display_time, display_note) = if is_selected_clip @@ -697,6 +707,49 @@ impl PianoRollPane { } } + // Copy/Cut/Paste — winit converts Ctrl+C/X/V to Event::Copy/Cut/Paste + let (has_copy, has_cut, has_paste) = ui.input(|i| { + let mut copy = false; + let mut cut = false; + let mut paste = false; + for event in &i.events { + match event { + egui::Event::Copy => copy = true, + egui::Event::Cut => cut = true, + egui::Event::Paste(_) => paste = true, + _ => {} + } + } + (copy, cut, paste) + }); + + if has_copy && !self.selected_note_indices.is_empty() { + if let Some(clip_id) = self.selected_clip_id { + self.copy_selected_notes(clip_id, shared); + *shared.clipboard_consumed = true; + } + } + + if has_cut && !self.selected_note_indices.is_empty() { + if let Some(clip_id) = self.selected_clip_id { + self.copy_selected_notes(clip_id, shared); + self.delete_selected_notes(clip_id, shared, clip_data); + *shared.clipboard_consumed = true; + } + } + + if has_paste { + if let Some(clip_id) = self.selected_clip_id { + // Only consume if clipboard has MIDI notes + if shared.clipboard_manager.has_content() { + if let Some(lightningbeam_core::clipboard::ClipboardContent::MidiNotes { .. }) = shared.clipboard_manager.paste() { + self.paste_notes(clip_id, shared, clip_data); + *shared.clipboard_consumed = true; + } + } + } + } + // Immediate press detection (fires on the actual press frame, before egui's drag threshold). // This ensures note preview and hit testing use the real press position. let pointer_just_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); @@ -823,7 +876,8 @@ impl PianoRollPane { // Create new note if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) { let clip_start = selected_clip.1; - let clip_local_time = (time - clip_start).max(0.0); + let trim_start = selected_clip.2; + let clip_local_time = (time - clip_start).max(0.0) + trim_start; self.creating_note = Some(TempNote { note, start_time: clip_local_time, @@ -857,7 +911,8 @@ impl PianoRollPane { if let Some(ref mut temp) = self.creating_note { if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) { let clip_start = selected_clip.1; - let clip_local_time = (time - clip_start).max(0.0); + let trim_start = selected_clip.2; + let clip_local_time = (time - clip_start).max(0.0) + trim_start; temp.duration = (clip_local_time - temp.start_time).max(MIN_NOTE_DURATION); } } @@ -951,9 +1006,15 @@ impl PianoRollPane { let resolved = Self::resolve_notes(events); let clip_info = clip_data.iter().find(|c| c.0 == clip_id)?; let timeline_start = clip_info.1; + let trim_start = clip_info.2; + let clip_duration = clip_info.3; for (i, note) in resolved.iter().enumerate().rev() { - let x = self.time_to_x(timeline_start + note.start_time, grid_rect); + // Skip notes outside trim window + if note.start_time + note.duration <= trim_start || note.start_time >= trim_start + clip_duration { + continue; + } + let x = self.time_to_x(timeline_start + (note.start_time - trim_start), grid_rect); let y = self.note_to_y(note.note, grid_rect); let w = (note.duration as f32 * self.pixels_per_second).max(2.0); let note_rect = Rect::from_min_size(pos2(x, y), vec2(w, self.note_height - 2.0)); @@ -977,9 +1038,15 @@ impl PianoRollPane { let resolved = Self::resolve_notes(events); let clip_info = clip_data.iter().find(|c| c.0 == clip_id)?; let timeline_start = clip_info.1; + let trim_start = clip_info.2; + let clip_duration = clip_info.3; for (i, note) in resolved.iter().enumerate().rev() { - let x = self.time_to_x(timeline_start + note.start_time, grid_rect); + // Skip notes outside trim window + if note.start_time + note.duration <= trim_start || note.start_time >= trim_start + clip_duration { + continue; + } + let x = self.time_to_x(timeline_start + (note.start_time - trim_start), grid_rect); let y = self.note_to_y(note.note, grid_rect); let w = (note.duration as f32 * self.pixels_per_second).max(2.0); let note_rect = Rect::from_min_size(pos2(x, y), vec2(w, self.note_height - 2.0)); @@ -1022,9 +1089,15 @@ impl PianoRollPane { None => return, }; let timeline_start = clip_info.1; + let trim_start = clip_info.2; + let clip_duration = clip_info.3; for (i, note) in resolved.iter().enumerate() { - let x = self.time_to_x(timeline_start + note.start_time, grid_rect); + // Skip notes outside trim window + if note.start_time + note.duration <= trim_start || note.start_time >= trim_start + clip_duration { + continue; + } + let x = self.time_to_x(timeline_start + (note.start_time - trim_start), grid_rect); let y = self.note_to_y(note.note, grid_rect); let w = (note.duration as f32 * self.pixels_per_second).max(2.0); let note_rect = Rect::from_min_size(pos2(x, y), vec2(w, self.note_height - 2.0)); @@ -1164,6 +1237,92 @@ impl PianoRollPane { self.cached_clip_id = None; } + fn copy_selected_notes(&self, clip_id: u32, shared: &mut SharedPaneState) { + let events = match shared.midi_event_cache.get(&clip_id) { + Some(e) => e, + None => return, + }; + let resolved = Self::resolve_notes(events); + + // Collect selected notes + let selected: Vec<&ResolvedNote> = self.selected_note_indices.iter() + .filter_map(|&i| resolved.get(i)) + .collect(); + + if selected.is_empty() { + return; + } + + // Find earliest start time as base offset + let min_time = selected.iter() + .map(|n| n.start_time) + .fold(f64::INFINITY, f64::min); + + // Store as relative times + let notes: Vec<(f64, u8, u8, f64)> = selected.iter() + .map(|n| (n.start_time - min_time, n.note, n.velocity, n.duration)) + .collect(); + + shared.clipboard_manager.copy( + lightningbeam_core::clipboard::ClipboardContent::MidiNotes { notes } + ); + } + + fn paste_notes( + &mut self, + clip_id: u32, + shared: &mut SharedPaneState, + clip_data: &[(u32, f64, f64, f64, Uuid)], + ) { + let notes_to_paste = match shared.clipboard_manager.paste() { + Some(lightningbeam_core::clipboard::ClipboardContent::MidiNotes { notes }) => notes, + _ => return, + }; + + if notes_to_paste.is_empty() { + return; + } + + // Get clip info for trim offset + let clip_info = match clip_data.iter().find(|c| c.0 == clip_id) { + Some(c) => c, + None => return, + }; + let clip_start = clip_info.1; + let trim_start = clip_info.2; + + // Place pasted notes at current playhead position (clip-local time) + let paste_time = (*shared.playback_time - clip_start).max(0.0) + trim_start; + + let events = match shared.midi_event_cache.get(&clip_id) { + Some(e) => e, + None => return, + }; + let mut resolved = Self::resolve_notes(events); + let old_notes = Self::notes_to_backend_format(&resolved); + + let paste_start_index = resolved.len(); + for &(rel_time, note, velocity, duration) in ¬es_to_paste { + resolved.push(ResolvedNote { + note, + start_time: paste_time + rel_time, + duration, + velocity, + }); + } + let new_notes = Self::notes_to_backend_format(&resolved); + + Self::update_cache_from_resolved(clip_id, &resolved, shared); + self.push_update_action("Paste notes", clip_id, old_notes, new_notes, shared, clip_data); + + // Select the pasted notes + self.selected_note_indices.clear(); + for i in paste_start_index..resolved.len() { + self.selected_note_indices.insert(i); + } + self.cached_clip_id = None; + } + fn push_update_action( &self, description: &str, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 2addb83..a4c9e68 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -1253,7 +1253,8 @@ impl TimelinePane { // Track preview trim values for waveform rendering let mut preview_trim_start = clip_instance.trim_start; - let mut preview_clip_duration = clip_duration; + let preview_trim_end_default = clip_instance.trim_end.unwrap_or(clip_duration); + let mut preview_clip_duration = (preview_trim_end_default - preview_trim_start).max(0.0); if let Some(drag_type) = self.clip_drag_state { if is_selected || is_linked_to_dragged { @@ -1301,6 +1302,7 @@ impl TimelinePane { // Update preview trim for waveform rendering preview_trim_start = new_trim_start; + preview_clip_duration = instance_duration; } ClipDragType::TrimRight => { // Trim right: extend or reduce duration with snap to adjacent clips @@ -1443,8 +1445,8 @@ impl TimelinePane { // Draw the clip instance background(s) // For looping clips, draw each iteration as a separate rounded rect - let trim_end_for_bg = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window_for_bg = (trim_end_for_bg - clip_instance.trim_start).max(0.0); + // Use preview_clip_duration so trim drag previews don't show false loop iterations + let content_window_for_bg = preview_clip_duration.max(0.0); let is_looping_bg = instance_duration > content_window_for_bg + 0.001 && content_window_for_bg > 0.0; if is_looping_bg { @@ -1502,8 +1504,8 @@ impl TimelinePane { lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => { if let Some(events) = midi_event_cache.get(midi_clip_id) { // Calculate content window for loop detection - let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window = (preview_trim_end - preview_trim_start).max(0.0); + // preview_clip_duration accounts for TrimLeft/TrimRight drag previews + let content_window = preview_clip_duration.max(0.0); let is_looping = instance_duration > content_window + 0.001; if is_looping && content_window > 0.0 { @@ -1527,7 +1529,7 @@ impl TimelinePane { clip_rect, rect.min.x, events, - clip_instance.trim_start, + preview_trim_start, iter_duration, iter_start, self.viewport_start_time, @@ -1543,7 +1545,7 @@ impl TimelinePane { clip_rect, rect.min.x, events, - clip_instance.trim_start, + preview_trim_start, instance_duration, instance_start, self.viewport_start_time,