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))),
|
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
|
// Send response back
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,8 @@ pub enum Query {
|
||||||
GetProject,
|
GetProject,
|
||||||
/// Set the project (replaces current project state)
|
/// Set the project (replaces current project state)
|
||||||
SetProject(Box<crate::audio::project::Project>),
|
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
|
/// Oscilloscope data from a node
|
||||||
|
|
@ -478,4 +480,6 @@ pub enum QueryResponse {
|
||||||
ProjectRetrieved(Result<Box<crate::audio::project::Project>, String>),
|
ProjectRetrieved(Result<Box<crate::audio::project::Project>, String>),
|
||||||
/// Project set
|
/// Project set
|
||||||
ProjectSet(Result<(), String>),
|
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);
|
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,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let document = self.action_executor.document();
|
// Gather all data from document in a scoped block so the borrow is released
|
||||||
let selection = &self.selection;
|
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
|
// Find selected clip instances on the active layer
|
||||||
let clips_to_duplicate: Vec<lightningbeam_core::clip::ClipInstance> = {
|
let clips_to_duplicate: Vec<lightningbeam_core::clip::ClipInstance> = {
|
||||||
let layer = match document.get_layer(&active_layer_id) {
|
let layer = match document.get_layer(&active_layer_id) {
|
||||||
Some(l) => l,
|
Some(l) => l,
|
||||||
None => return,
|
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,
|
if clips_to_duplicate.is_empty() {
|
||||||
AnyLayer::Audio(al) => &al.clip_instances,
|
return;
|
||||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
}
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
|
||||||
};
|
// For MIDI clips, duplicate the backend clip to get independent note data.
|
||||||
instances.iter()
|
let mut midi_clip_replacements: std::collections::HashMap<uuid::Uuid, (uuid::Uuid, lightningbeam_core::clip::AudioClip)> = std::collections::HashMap::new();
|
||||||
.filter(|ci| selection.contains_clip_instance(&ci.id))
|
if let Some(ref controller_arc) = self.audio_controller {
|
||||||
.cloned()
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
.collect()
|
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)
|
||||||
};
|
};
|
||||||
|
// document borrow is now released
|
||||||
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();
|
|
||||||
|
|
||||||
let new_ids: Vec<uuid::Uuid> = duplicates.iter().map(|d| d.id).collect();
|
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 {
|
for duplicate in duplicates {
|
||||||
let action = AddClipInstanceAction::new(active_layer_id, duplicate);
|
let action = AddClipInstanceAction::new(active_layer_id, duplicate);
|
||||||
|
|
||||||
|
|
@ -4632,6 +4690,7 @@ impl eframe::App for EditorApp {
|
||||||
|
|
||||||
// Main pane area (editor mode)
|
// Main pane area (editor mode)
|
||||||
let mut layout_action: Option<LayoutAction> = None;
|
let mut layout_action: Option<LayoutAction> = None;
|
||||||
|
let mut clipboard_consumed = false;
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
let available_rect = ui.available_rect_before_wrap();
|
let available_rect = ui.available_rect_before_wrap();
|
||||||
|
|
||||||
|
|
@ -4753,6 +4812,7 @@ impl eframe::App for EditorApp {
|
||||||
region_selection: &mut self.region_selection,
|
region_selection: &mut self.region_selection,
|
||||||
region_select_mode: &mut self.region_select_mode,
|
region_select_mode: &mut self.region_select_mode,
|
||||||
pending_graph_loads: &self.pending_graph_loads,
|
pending_graph_loads: &self.pending_graph_loads,
|
||||||
|
clipboard_consumed: &mut clipboard_consumed,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_layout_node(
|
render_layout_node(
|
||||||
|
|
@ -4908,18 +4968,21 @@ impl eframe::App for EditorApp {
|
||||||
// Handle clipboard events (Ctrl+C/X/V) — winit converts these to
|
// Handle clipboard events (Ctrl+C/X/V) — winit converts these to
|
||||||
// Event::Copy/Cut/Paste instead of regular key events, so
|
// Event::Copy/Cut/Paste instead of regular key events, so
|
||||||
// check_shortcuts won't see them via key_pressed().
|
// check_shortcuts won't see them via key_pressed().
|
||||||
for event in &i.events {
|
// Skip if a pane (e.g. piano roll) already handled the clipboard event.
|
||||||
match event {
|
if !clipboard_consumed {
|
||||||
egui::Event::Copy => {
|
for event in &i.events {
|
||||||
self.handle_menu_action(MenuAction::Copy);
|
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,
|
region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
|
||||||
/// Counter for in-flight graph preset loads (keeps repaint loop alive)
|
/// Counter for in-flight graph preset loads (keeps repaint loop alive)
|
||||||
pending_graph_loads: &'a std::sync::Arc<std::sync::atomic::AtomicU32>,
|
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
|
/// Recursively render a layout node with drag support
|
||||||
|
|
@ -5576,6 +5641,7 @@ fn render_pane(
|
||||||
region_selection: ctx.region_selection,
|
region_selection: ctx.region_selection,
|
||||||
region_select_mode: ctx.region_select_mode,
|
region_select_mode: ctx.region_select_mode,
|
||||||
pending_graph_loads: ctx.pending_graph_loads,
|
pending_graph_loads: ctx.pending_graph_loads,
|
||||||
|
clipboard_consumed: ctx.clipboard_consumed,
|
||||||
editing_clip_id: ctx.editing_clip_id,
|
editing_clip_id: ctx.editing_clip_id,
|
||||||
editing_instance_id: ctx.editing_instance_id,
|
editing_instance_id: ctx.editing_instance_id,
|
||||||
editing_parent_layer_id: ctx.editing_parent_layer_id,
|
editing_parent_layer_id: ctx.editing_parent_layer_id,
|
||||||
|
|
@ -5657,6 +5723,7 @@ fn render_pane(
|
||||||
region_selection: ctx.region_selection,
|
region_selection: ctx.region_selection,
|
||||||
region_select_mode: ctx.region_select_mode,
|
region_select_mode: ctx.region_select_mode,
|
||||||
pending_graph_loads: ctx.pending_graph_loads,
|
pending_graph_loads: ctx.pending_graph_loads,
|
||||||
|
clipboard_consumed: ctx.clipboard_consumed,
|
||||||
editing_clip_id: ctx.editing_clip_id,
|
editing_clip_id: ctx.editing_clip_id,
|
||||||
editing_instance_id: ctx.editing_instance_id,
|
editing_instance_id: ctx.editing_instance_id,
|
||||||
editing_parent_layer_id: ctx.editing_parent_layer_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
|
/// GraphLoadPreset command so the repaint loop stays alive until the
|
||||||
/// audio thread sends GraphPresetLoaded back
|
/// audio thread sends GraphPresetLoaded back
|
||||||
pub pending_graph_loads: &'a std::sync::Arc<std::sync::atomic::AtomicU32>,
|
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
|
/// Trait for pane rendering
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,7 @@ impl PianoRollPane {
|
||||||
for instance in &audio_layer.clip_instances {
|
for instance in &audio_layer.clip_instances {
|
||||||
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
|
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
|
||||||
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
|
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));
|
clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -325,7 +325,7 @@ impl PianoRollPane {
|
||||||
// Render notes
|
// Render notes
|
||||||
if let Some(events) = shared.midi_event_cache.get(&midi_clip_id) {
|
if let Some(events) = shared.midi_event_cache.get(&midi_clip_id) {
|
||||||
let resolved = Self::resolve_notes(events);
|
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(ref temp) = self.creating_note {
|
||||||
if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
|
if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
|
||||||
let timeline_start = selected_clip.1;
|
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 y = self.note_to_y(temp.note, grid_rect);
|
||||||
let w = (temp.duration as f32 * self.pixels_per_second).max(2.0);
|
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));
|
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,
|
grid_rect: Rect,
|
||||||
notes: &[ResolvedNote],
|
notes: &[ResolvedNote],
|
||||||
clip_timeline_start: f64,
|
clip_timeline_start: f64,
|
||||||
_trim_start: f64,
|
trim_start: f64,
|
||||||
|
clip_duration: f64,
|
||||||
opacity: f32,
|
opacity: f32,
|
||||||
is_selected_clip: bool,
|
is_selected_clip: bool,
|
||||||
) {
|
) {
|
||||||
for (i, note) in notes.iter().enumerate() {
|
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
|
// Apply drag offset for selected notes during move
|
||||||
let (display_time, display_note) = if is_selected_clip
|
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).
|
// 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.
|
// 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));
|
let pointer_just_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary));
|
||||||
|
|
@ -823,7 +876,8 @@ impl PianoRollPane {
|
||||||
// Create new note
|
// Create new note
|
||||||
if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
|
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_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 {
|
self.creating_note = Some(TempNote {
|
||||||
note,
|
note,
|
||||||
start_time: clip_local_time,
|
start_time: clip_local_time,
|
||||||
|
|
@ -857,7 +911,8 @@ impl PianoRollPane {
|
||||||
if let Some(ref mut temp) = self.creating_note {
|
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) {
|
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_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);
|
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 resolved = Self::resolve_notes(events);
|
||||||
let clip_info = clip_data.iter().find(|c| c.0 == clip_id)?;
|
let clip_info = clip_data.iter().find(|c| c.0 == clip_id)?;
|
||||||
let timeline_start = clip_info.1;
|
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() {
|
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 y = self.note_to_y(note.note, grid_rect);
|
||||||
let w = (note.duration as f32 * self.pixels_per_second).max(2.0);
|
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));
|
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 resolved = Self::resolve_notes(events);
|
||||||
let clip_info = clip_data.iter().find(|c| c.0 == clip_id)?;
|
let clip_info = clip_data.iter().find(|c| c.0 == clip_id)?;
|
||||||
let timeline_start = clip_info.1;
|
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() {
|
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 y = self.note_to_y(note.note, grid_rect);
|
||||||
let w = (note.duration as f32 * self.pixels_per_second).max(2.0);
|
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));
|
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,
|
None => return,
|
||||||
};
|
};
|
||||||
let timeline_start = clip_info.1;
|
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() {
|
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 y = self.note_to_y(note.note, grid_rect);
|
||||||
let w = (note.duration as f32 * self.pixels_per_second).max(2.0);
|
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));
|
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;
|
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(
|
fn push_update_action(
|
||||||
&self,
|
&self,
|
||||||
description: &str,
|
description: &str,
|
||||||
|
|
|
||||||
|
|
@ -1253,7 +1253,8 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Track preview trim values for waveform rendering
|
// Track preview trim values for waveform rendering
|
||||||
let mut preview_trim_start = clip_instance.trim_start;
|
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 let Some(drag_type) = self.clip_drag_state {
|
||||||
if is_selected || is_linked_to_dragged {
|
if is_selected || is_linked_to_dragged {
|
||||||
|
|
@ -1301,6 +1302,7 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Update preview trim for waveform rendering
|
// Update preview trim for waveform rendering
|
||||||
preview_trim_start = new_trim_start;
|
preview_trim_start = new_trim_start;
|
||||||
|
preview_clip_duration = instance_duration;
|
||||||
}
|
}
|
||||||
ClipDragType::TrimRight => {
|
ClipDragType::TrimRight => {
|
||||||
// Trim right: extend or reduce duration with snap to adjacent clips
|
// Trim right: extend or reduce duration with snap to adjacent clips
|
||||||
|
|
@ -1443,8 +1445,8 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Draw the clip instance background(s)
|
// Draw the clip instance background(s)
|
||||||
// For looping clips, draw each iteration as a separate rounded rect
|
// For looping clips, draw each iteration as a separate rounded rect
|
||||||
let trim_end_for_bg = clip_instance.trim_end.unwrap_or(clip_duration);
|
// Use preview_clip_duration so trim drag previews don't show false loop iterations
|
||||||
let content_window_for_bg = (trim_end_for_bg - clip_instance.trim_start).max(0.0);
|
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;
|
let is_looping_bg = instance_duration > content_window_for_bg + 0.001 && content_window_for_bg > 0.0;
|
||||||
|
|
||||||
if is_looping_bg {
|
if is_looping_bg {
|
||||||
|
|
@ -1502,8 +1504,8 @@ impl TimelinePane {
|
||||||
lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => {
|
lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => {
|
||||||
if let Some(events) = midi_event_cache.get(midi_clip_id) {
|
if let Some(events) = midi_event_cache.get(midi_clip_id) {
|
||||||
// Calculate content window for loop detection
|
// Calculate content window for loop detection
|
||||||
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
// preview_clip_duration accounts for TrimLeft/TrimRight drag previews
|
||||||
let content_window = (preview_trim_end - preview_trim_start).max(0.0);
|
let content_window = preview_clip_duration.max(0.0);
|
||||||
let is_looping = instance_duration > content_window + 0.001;
|
let is_looping = instance_duration > content_window + 0.001;
|
||||||
|
|
||||||
if is_looping && content_window > 0.0 {
|
if is_looping && content_window > 0.0 {
|
||||||
|
|
@ -1527,7 +1529,7 @@ impl TimelinePane {
|
||||||
clip_rect,
|
clip_rect,
|
||||||
rect.min.x,
|
rect.min.x,
|
||||||
events,
|
events,
|
||||||
clip_instance.trim_start,
|
preview_trim_start,
|
||||||
iter_duration,
|
iter_duration,
|
||||||
iter_start,
|
iter_start,
|
||||||
self.viewport_start_time,
|
self.viewport_start_time,
|
||||||
|
|
@ -1543,7 +1545,7 @@ impl TimelinePane {
|
||||||
clip_rect,
|
clip_rect,
|
||||||
rect.min.x,
|
rect.min.x,
|
||||||
events,
|
events,
|
||||||
clip_instance.trim_start,
|
preview_trim_start,
|
||||||
instance_duration,
|
instance_duration,
|
||||||
instance_start,
|
instance_start,
|
||||||
self.viewport_start_time,
|
self.viewport_start_time,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue