diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index ef6977f3..6ba40c7c 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1378,6 +1378,20 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { let id = ui.id().with(("lane", track.id.0)); let resp = ui.interact(lane, id, Sense::click_and_drag()); + // The clip menu is attached before the early return below: once the menu is open the pointer + // is over the popup, the lane has no hover position, and the rest of this function bails out. + // The clip is remembered at the right-click so the menu keeps its target while it is used. + let menu_clip_id = id.with("menu_clip"); + if resp.secondary_clicked() { + let clip = resp.interact_pointer_pos().and_then(|p| { + let s = app.engine.session(); + clip_at(track, sample_at(s, tl, p.x).max(0)).map(|c| c.id) + }); + ui.ctx().data_mut(|d| d.insert_temp(menu_clip_id, clip)); + } + // The clip may have been removed or moved since the right-click; only use it if it still exists. + let menu_clip = ui.ctx().data(|d| d.get_temp::>(menu_clip_id)).flatten().filter(|c| track.clips().iter().any(|x| x.id == *c)); + resp.context_menu(|ui| clip_context_menu(app, ui, menu_clip)); // Audio files dropped from the Clip List. if let Some(src) = resp.dnd_release_payload::() && let Some(p) = ui.ctx().pointer_latest_pos() @@ -1619,7 +1633,6 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, { let _ = app.run("edit.select", json!({"clips": [c.id.0]})); } - resp.context_menu(|ui| clip_context_menu(app, ui, hit.as_ref().map(|c| c.id))); } fn automation_id(p: &AutoParam) -> String { diff --git a/crates/ui-egui/tests/clip_menu.rs b/crates/ui-egui/tests/clip_menu.rs new file mode 100644 index 00000000..71684a92 --- /dev/null +++ b/crates/ui-egui/tests/clip_menu.rs @@ -0,0 +1,66 @@ +//! Regression: the clip context menu must stay open while the pointer moves onto it, so its +//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane +//! returned early (no hover position) before the menu was drawn. + +use egui::{Event, Modifiers, PointerButton, pos2, vec2}; +use egui_kittest::Harness; +use soundcraft_ui_egui::{Services, SoundApp}; + +/// One UI frame, with queued synthetic pointer input applied first (as the control channel does). +fn step(h: &mut Harness<'_, SoundApp>) { + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().raw_input_hook(&mut raw); + *h.input_mut() = raw; + h.step(); +} + +fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) { + let pos = pos2(x, y); + let s = &mut h.state_mut().synthetic; + s.push(Event::PointerMoved(pos)); + s.push(Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }); + s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); +} + +fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize { + let s = h.state().engine.session(); + s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len()) +} + +#[test] +fn clip_menu_stays_open_while_moving_onto_delete() { + let engine = soundcraft_engine::demo::demo_engine(); + let app = SoundApp::new(engine, None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..6 { + step(&mut h); + } + let before = kick_clip_count(&h); + assert!(before > 0, "the demo session should have a recorded Kick clip"); + + // Right-click the Kick clip, then move the pointer into the menu and down to Delete. + press(&mut h, 880.0, 240.0, PointerButton::Secondary); + for _ in 0..10 { + step(&mut h); + } + // Move (no click) into the menu, as a real pointer would, so the lane loses its hover. + for (x, y) in [(900.0, 300.0), (907.0, 442.0)] { + h.state_mut().synthetic.push(Event::PointerMoved(pos2(x, y))); + for _ in 0..10 { + step(&mut h); + } + } + press(&mut h, 907.0, 442.0, PointerButton::Primary); + for _ in 0..15 { + step(&mut h); + } + + assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip"); +}