From bd906d3ee9a3df6019e69d5d5b2bb0f6a8ab1551 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:52:24 +0700 Subject: [PATCH 1/3] fix(timeline): create composition when footage is dropped into an empty timeline (#649) --- crates/ui-egui/src/panels/timeline.rs | 28 +++++++++++++++++ crates/ui-egui/tests/ui_drag_drop.rs | 44 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 086e46dc..686420f2 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1212,6 +1212,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ctx = ui.ctx().clone(); let Some(cid) = app.session.active_comp_id() else { ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + // Dropping footage into an empty Timeline should create its first composition, + // just like dropping onto the Project panel's New Composition button (#649). + if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() + && ui.rect_contains_pointer(rect) + { + let items: Vec = crate::panels::project_drop_items(app, *dragged) + .into_iter() + .map(ItemId) + .filter(|id| { + app.session.project.item(*id).is_some_and(|item| matches!( + &item.kind, + effectcraft_engine::project::ItemKind::Footage(_) + | effectcraft_engine::project::ItemKind::Solid(_) + )) + }) + .collect(); + if !items.is_empty() { + ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition"); + if ctx.input(|i| i.pointer.any_released()) { + app.session.state.project_selection = items; + if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) { + app.ui.status = e; + } + egui::DragAndDrop::clear_payload(&ctx); + } + } + } return; }; let Some(comp) = app.session.project.comp_arc(cid) else { return }; diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index d9e04ec3..a7254235 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -210,6 +210,50 @@ fn project_items_dropped_on_new_comp_make_a_composition() { assert_eq!(comps(&h), before + 1, "nothing made yet"); } +/// #649: a Project footage item dropped into an empty Timeline makes a composition +/// with that item's dimensions, frame rate, duration, and a source layer. +#[test] +fn project_footage_dropped_on_empty_timeline_creates_first_comp() { + let mut s = Session::default(); + let footage = s.execute( + "file.importPlaceholder", + json!({"name": "Landscape.png", "width": 640, "height": 360, "frameRate": 24, "duration": 5}), + ).unwrap()["item"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.active_comp().is_none()); + let from = rect(&h, &format!("project.item.{footage}.name")).center(); + let to = rect(&h, "panel.Timeline").center(); + drag(&mut h, from, to, Modifiers::NONE); + let comp = h.state().session.active_comp().expect("created a composition"); + assert_eq!((comp.width, comp.height), (640, 360)); + assert_eq!(comp.frame_rate, effectcraft_engine::time::FrameRate::from_f64(24.0)); + assert_eq!(comp.duration.seconds(), 5.0); + assert_eq!(comp.layers.len(), 1); + assert_eq!(comp.layers[0].name, "Landscape.png"); + assert!(h.state().dialog.is_none()); +} + +/// Multiple source items use the existing New Comp from Selection dialog, rather than +/// quietly creating only the dragged item or changing the composition settings. +#[test] +fn multiple_selected_footage_dropped_on_empty_timeline_asks_how_to_create_comps() { + let mut s = Session::default(); + let first = s.execute("file.importPlaceholder", json!({"name": "First.png", "width": 160, "height": 90})).unwrap()["item"].as_u64().unwrap(); + let second = s.execute("file.importPlaceholder", json!({"name": "Second.png", "width": 300, "height": 200})).unwrap()["item"].as_u64().unwrap(); + s.state.project_selection = vec![ + effectcraft_engine::project::ItemId(first), + effectcraft_engine::project::ItemId(second), + ]; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let from = rect(&h, &format!("project.item.{first}.name")).center(); + drag(&mut h, from, rect(&h, "panel.Timeline").center(), Modifiers::NONE); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some()); + assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses"); +} + /// A file dropped on the window (egui hands it over as a dropped file). #[derive(Debug)] struct Dropped(std::path::PathBuf); From a9f4d92d5a333149bb36f2c8f3e0d71ab089c40a Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:55:57 +0700 Subject: [PATCH 2/3] test(timeline): avoid overlapping harness borrows in multi-drop regression --- crates/ui-egui/tests/ui_drag_drop.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index a7254235..fc6a9a69 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -248,7 +248,8 @@ fn multiple_selected_footage_dropped_on_empty_timeline_asks_how_to_create_comps( let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); let from = rect(&h, &format!("project.item.{first}.name")).center(); - drag(&mut h, from, rect(&h, "panel.Timeline").center(), Modifiers::NONE); + let to = rect(&h, "panel.Timeline").center(); + drag(&mut h, from, to, Modifiers::NONE); assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); assert!(h.state().auto.find("form.field.single").is_some()); assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses"); From 2925d9605a9480a8e3db9c9c0a55aa1365c88f84 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:57:32 +0700 Subject: [PATCH 3/3] fix(timeline): ignore non-media data items dropped onto empty timeline --- crates/ui-egui/src/panels/timeline.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 686420f2..d5050e16 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1221,11 +1221,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .into_iter() .map(ItemId) .filter(|id| { - app.session.project.item(*id).is_some_and(|item| matches!( - &item.kind, - effectcraft_engine::project::ItemKind::Footage(_) - | effectcraft_engine::project::ItemKind::Solid(_) - )) + app.session.project.item(*id).is_some_and(|item| match &item.kind { + effectcraft_engine::project::ItemKind::Footage(footage) => { + footage.kind != effectcraft_engine::project::FootageKind::Data + } + effectcraft_engine::project::ItemKind::Solid(_) => true, + _ => false, + }) }) .collect(); if !items.is_empty() {