From f1839dd1805c20bcd19e636a9d67e2a1f6e6889f Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:37:39 +0400 Subject: [PATCH] ui: import native file drops with drop-target hint (ui_drag_drop.rs) Add tests for file drop behavior and error handling. --- crates/ui-egui/tests/ui_drag_drop.rs | 61 +++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index db891256..6fe346d2 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -197,6 +197,9 @@ impl effectcraft_engine::Importer for Gated { while self.allow.load(Ordering::SeqCst) <= k && std::time::Instant::now() < until { std::thread::sleep(std::time::Duration::from_millis(1)); } + if path.ends_with(".unsupported") { + return Err("unsupported media format".into()); + } Ok(effectcraft_engine::project::Footage { path: path.into(), width: 64, height: 32, has_video: true, ..Default::default() }) } } @@ -221,6 +224,9 @@ fn dropped_files_show_import_progress_then_the_items_they_made() { let allow = Arc::new(AtomicUsize::new(1)); h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: allow.clone() })); let file = |n: &str| std::env::temp_dir().join(n); + // External files import even when dropped over the Timeline rather than the Project panel. + h.event(Event::PointerMoved(rect(&h, "panel.Timeline").center())); + h.step(); for n in ["a.png", "b.png", "c.png"] { h.input_mut().dropped_files.push(Arc::new(Dropped(file(n)))); } @@ -242,7 +248,9 @@ fn dropped_files_show_import_progress_then_the_items_they_made() { assert!(h.state().auto.find(&format!("project.item.{last}")).is_some(), "and in view in the Project panel"); assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 3 items")); - // The same file again: imported again, and that one is selected. + // The same file again over the Project panel: imported again and selected. + h.event(Event::PointerMoved(rect(&h, "panel.Project").center())); + h.step(); h.input_mut().dropped_files.push(Arc::new(Dropped(file("a.png")))); h.run_steps(2); step_until(&mut h, |h| items(h) == 4); @@ -253,6 +261,57 @@ fn dropped_files_show_import_progress_then_the_items_they_made() { assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 1 item")); } +#[test] +fn native_file_hover_shows_drop_target_hint() { + let (mut h, _) = harness(); + for name in ["one.mp4", "two.mov"] { + h.input_mut().hovered_files.push(egui::HoveredFile { path: Some(std::env::temp_dir().join(name)), mime: String::new() }); + } + h.step(); + assert_eq!(h.state().auto.find("file.dropTarget").map(|e| e.label.as_str()), Some("Drop 2 files to import")); +} + +#[test] +fn dropped_file_without_local_path_explains_the_fallback() { + let (mut h, _) = harness(); + h.input_mut().dropped_files.push(Arc::new(Dropped("relative.mp4".into()))); + h.run_steps(2); + assert!(h.state().ui.status.contains("without a local path"), "{}", h.state().ui.status); + assert!(h.state().ui.status.contains("File > Import")); +} + +#[test] +fn dropped_file_on_viewer_imports_and_adds_layer_at_pointer() { + let (mut h, _) = harness(); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) })); + let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); + h.event(Event::PointerMoved(to)); + h.step(); + h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join("viewer-drop.mp4")))); + h.run_steps(2); + step_until(&mut h, |h| h.state().session.active_comp().is_some_and(|c| c.layers.iter().any(|l| l.name == "viewer-drop.mp4"))); + let comp = h.state().session.active_comp().unwrap(); + let layer = comp.layers.iter().find(|l| l.name == "viewer-drop.mp4").unwrap(); + let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; + let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; + assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); +} + +#[test] +fn an_unsupported_drop_reports_the_error_and_keeps_valid_files() { + let (mut h, _) = harness(); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) })); + for name in ["valid.mp4", "bad.unsupported"] { + h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join(name)))); + } + h.run_steps(2); + step_until(&mut h, |h| h.state().session.job_log.iter().any(|j| j.kind == "import" && j.message.contains("unsupported media format"))); + let job = h.state().session.job_log.iter().find(|j| j.kind == "import").unwrap(); + assert!(job.message.contains("bad.unsupported: unsupported media format"), "{}", job.message); + assert_eq!(job.result["errors"].as_array().map(Vec::len), Some(1)); + assert!(h.state().session.project.items.values().any(|i| i.name == "valid.mp4")); +} + /// The automation id of the Importing card on screen. fn card_id(h: &Harness<'_, EffectcraftApp>) -> String { h.state().auto.query("import.job.").first().map(|e| e.id.clone()).unwrap_or_default()