piano roll and clip resizing fixes

This commit is contained in:
Skyler Lehmkuhl 2026-02-21 18:45:46 -05:00
parent 16011e5f28
commit 4122fda954
6 changed files with 300 additions and 59 deletions

View File

@ -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

View File

@ -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>),
}

View File

@ -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,6 +1915,8 @@ impl EditorApp {
None => return,
};
// 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;
@ -1937,18 +1942,71 @@ impl EditorApp {
return;
}
// Collect all duplicate instances upfront to release the document borrow
// 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)
};
// 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,6 +4968,8 @@ 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().
// 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 => {
@ -4922,6 +4984,7 @@ impl eframe::App for EditorApp {
_ => {}
}
}
}
// 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)
@ -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,

View File

@ -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

View File

@ -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 &notes_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,

View File

@ -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,