piano roll and clip resizing fixes
This commit is contained in:
parent
16011e5f28
commit
4122fda954
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -405,6 +405,8 @@ pub enum Query {
|
|||
GetProject,
|
||||
/// Set the project (replaces current project state)
|
||||
SetProject(Box<crate::audio::project::Project>),
|
||||
/// 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<Box<crate::audio::project::Project>, String>),
|
||||
/// Project set
|
||||
ProjectSet(Result<(), String>),
|
||||
/// MIDI clip duplicated (returns new clip ID)
|
||||
MidiClipDuplicated(Result<MidiClipId, String>),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<lightningbeam_core::clip::ClipInstance> = {
|
||||
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<lightningbeam_core::clip::ClipInstance> = {
|
||||
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<uuid::Uuid, (uuid::Uuid, lightningbeam_core::clip::AudioClip)> = 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<lightningbeam_core::clip::ClipInstance> = 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<lightningbeam_core::clip::ClipInstance> = 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<uuid::Uuid> = 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<LayoutAction> = 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<std::sync::atomic::AtomicU32>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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<std::sync::atomic::AtomicU32>,
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue