From 53d6b396aceffef000ab15c4d4a33f0f3286e149 Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:36:31 +0400 Subject: [PATCH 1/5] Update README with Linux file drag-and-drop details Added information about file drag-and-drop functionality on Linux with Wayland backend. --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index a96364df..7116dac7 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,20 @@ Installers and executables are code-signed. | Fedora/RHEL/openSUSE | `effectcraft--linux-x86_64.rpm` | `effectcraft--linux-aarch64.rpm` | | | Tarball | `effectcraft--linux-x86_64.tar.gz` | `effectcraft--linux-aarch64.tar.gz` | Unpack anywhere | +**File drag-and-drop on Linux:** The current eframe/winit 0.30 native Wayland backend does not +deliver file-hover or file-drop events. On Hyprland and other native Wayland sessions, use +**File ▸ Import ▸ File…** (which accepts multiple files), or start EffectCraft via XWayland if +XWayland is installed and `DISPLAY` is set: + +```sh +env -u WAYLAND_DISPLAY effectcraft +``` + +When the backend delivers events (X11/XWayland, Windows and macOS), dropping media anywhere +in the window imports it, and a drop-target hint appears while hovering. On native Wayland the +app cannot detect an attempt that generates no event; it shows a startup notice with the +File ▸ Import fallback instead. + ### FreeBSD | Build | File | From bf4d616fc57e612e4e3c5d106f0630af21b31828 Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:36:52 +0400 Subject: [PATCH 2/5] ui: import native file drops with drop-target hint (prefs_live) --- crates/ui-egui/src/prefs_live.rs | 198 +++++++++++++++++++++++++------ 1 file changed, 160 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index 8627c9c6..242f962c 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -11,41 +11,122 @@ use crate::frames::FrameKey; /// Run once per frame, before the panels draw. pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) { - dropped_files(app, ctx); memory_watch(app, ctx); home_on_open(app, ctx); video_preview(app, ctx); } -/// Files dropped on the window are imported (layered files as Default Drag Import As says) in -/// the background, with the Importing card showing what is read (#270). Dropped on the -/// Composition viewer, they also become layers centred there, as in After Effects (#85), when the -/// platform reports where the pointer is during file drags. -fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) { - let paths: Vec = - ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| std::path::Path::new(p).is_absolute()).collect()); - if paths.is_empty() { +/// Import native file drops through the same command as the file picker. Run after the dock +/// layout to use the current viewer rectangle for drops that also add layers. +#[cfg(not(target_arch = "wasm32"))] +pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { + use egui::{Align2, Stroke, StrokeKind, vec2}; + + let ctx = ui.ctx().clone(); + let hovered = ctx.input(|i| i.raw.hovered_files.len()); + if hovered != 0 { + let label = if hovered == 1 { + "Drop file to import".to_string() + } else { + format!("Drop {hovered} files to import") + }; + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("native-file-drop"), + )); + painter.rect_stroke( + bounds.shrink(8.0), + 6.0, + Stroke::new(2.0, app.tokens.accent), + StrokeKind::Inside, + ); + let hint = Rect::from_center_size(bounds.center(), vec2(300.0, 46.0)); + painter.rect_filled(hint, 6.0, app.tokens.panel_bg); + painter.text( + hint.center(), + Align2::CENTER_CENTER, + &label, + crate::theme::Tokens::medium(14.0), + app.tokens.text, + ); + app.auto.add("file.dropTarget", hint, &label); + } + + let (paths, skipped) = ctx.input(|i| { + let mut paths = Vec::new(); + let mut skipped = 0; + for file in &i.raw.dropped_files { + if file.path().is_absolute() { + paths.push(file.path().to_string_lossy().into_owned()); + } else { + skipped += 1; + } + } + (paths, skipped) + }); + + if paths.is_empty() && skipped == 0 { return; } + + let mut errors = Vec::new(); + if skipped != 0 { + errors.push(format!( + "Skipped {skipped} dropped file(s) without a local path. Use File > Import > File..." + )); + } + let (proj, rest): (Vec, Vec) = paths.into_iter().partition(|p| { let l = p.to_ascii_lowercase(); l.ends_with(".ecproj") || l.ends_with(".ecprojx") }); - if let Some(p) = proj.first() - && let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p})) - { - app.ui.status = e; + + if proj.len() > 1 { + errors.push(format!( + "Only one project can be opened per drop; skipped {} more", + proj.len() - 1 + )); } + + if let Some(p) = proj.first() + && let Err(e) = crate::menus::invoke(app, &ctx, "file.open", json!({"path": p})) + { + errors.push(format!("Cannot open dropped project {p}: {e}")); + } + if !rest.is_empty() { - let viewer = app.auto.find("viewer.area").map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))); - let at = - ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(ctx, p)); - let params = - json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), "addToComp": at.is_some(), "position": at, "background": true}); - if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) { - app.ui.status = e; + let viewer = app.auto.elements.iter().find(|e| e.id == "viewer.area").map(|e| { + Rect::from_min_size( + egui::pos2(e.rect[0], e.rect[1]), + egui::vec2(e.rect[2], e.rect[3]), + ) + }); + + let at = if proj.is_empty() { + ctx.input(|i| i.pointer.hover_pos()) + .filter(|p| viewer.is_some_and(|v| v.contains(*p))) + .and_then(|p| crate::panels::viewer::screen_to_comp(&ctx, p)) + } else { + None + }; + + let params = json!({ + "paths": rest, + "drag": true, + "importAs": app.session.prefs.drag_import_as(), + "addToComp": at.is_some(), + "position": at, + "background": true + }); + + if let Err(e) = crate::menus::invoke(app, &ctx, "file.import", params) { + errors.push(format!("Cannot import dropped files: {e}")); } } + + if !errors.is_empty() { + app.ui.status = errors.join("; "); + } } /// Re-read the system's free memory every 30 s (cache budgets follow it; on Windows each @@ -110,10 +191,21 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { let opts = crate::panels::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); let t = match prev { Some((_, mut t)) => { - t.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts); + t.set( + crate::frames::fit_texture( + (*img).clone(), + crate::frames::max_texture_side(ctx), + ), + opts, + ); t } - None => crate::frames::load_fitted(ctx, "video-preview", (*img).clone(), opts), + None => crate::frames::load_fitted( + ctx, + "video-preview", + (*img).clone(), + opts, + ), }; ctx.data_mut(|d| d.insert_temp(tex_id, (k, t.clone()))); Some(t) @@ -122,27 +214,57 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { }, (_, _, prev) => prev.map(|p| p.1), }; - let title = - format!("Video Preview — {}", app.session.active_comp_id().and_then(|c| app.session.project.item(c)).map(|i| i.name.clone()).unwrap_or_default()); - let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([960.0, 540.0]); + + let title = format!( + "Video Preview — {}", + app.session + .active_comp_id() + .and_then(|c| app.session.project.item(c)) + .map(|i| i.name.clone()) + .unwrap_or_default() + ); + + let mut builder = egui::ViewportBuilder::default() + .with_title(title) + .with_inner_size([960.0, 540.0]); + if v.device == "fullscreen" { builder = builder.with_fullscreen(true); } + let mut closed = false; - ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("effectcraft-video-preview"), builder, |vctx, _| { - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(vctx, |ui| { - let area = ui.max_rect(); - if let Some(t) = &tex { - let [w, h] = t.size().map(|x| x.max(1) as f32); - let s = (area.width() / w).min(area.height() / h); - let r = Rect::from_center_size(area.center(), egui::vec2(w * s, h * s)); - ui.painter().image(t.id(), r, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("effectcraft-video-preview"), + builder, + |vctx, _| { + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(Color32::BLACK)) + .show(vctx, |ui| { + let area = ui.max_rect(); + if let Some(t) = &tex { + let [w, h] = t.size().map(|x| x.max(1) as f32); + let s = (area.width() / w).min(area.height() / h); + let r = Rect::from_center_size( + area.center(), + egui::vec2(w * s, h * s), + ); + ui.painter().image( + t.id(), + r, + Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1.0, 1.0), + ), + Color32::WHITE, + ); + } + }); + if vctx.input(|i| i.viewport().close_requested()) { + closed = true; } - }); - if vctx.input(|i| i.viewport().close_requested()) { - closed = true; - } - }); + }, + ); + if closed && let Err(e) = app.set_pref("video.enableOutput", Value::Bool(false)) { app.ui.status = e; } From 95c7cf1f05d7b9e243e9162030974b86db76e96f 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:19 +0400 Subject: [PATCH 3/5] ui: import native file drops with drop-target hint (main.rs) Add Wayland drop notice for unsupported file drops --- apps/effectcraft/src/main.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 72afab5a..c73e18ec 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -36,6 +36,13 @@ fn app_icon() -> egui::IconData { eframe::icon_data::from_png_bytes(png).unwrap_or_default() } +/// winit 0.30 cannot deliver native Wayland file drops. XWayland can. +#[cfg(target_os = "linux")] +fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> { + (wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11"))) + .then_some("Native Wayland file drops are unavailable. Use File > Import > File... or run via XWayland (see docs/footage.md).") +} + /// The command line. #[derive(Debug, Default, PartialEq)] struct Args { @@ -170,6 +177,14 @@ fn main() -> eframe::Result { if let Some(r) = recovery { app.offer_recovery(r); } + #[cfg(target_os = "linux")] + if let Some(note) = wayland_drop_notice( + std::env::var_os("WAYLAND_DISPLAY").as_deref(), + std::env::var_os("WINIT_UNIX_BACKEND").as_deref(), + ) && app.ui.status.is_empty() + { + app.ui.status = note.into(); + } app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect() })); @@ -340,6 +355,17 @@ mod tests { parse_args(args.iter().map(|a| a.to_string()), None) } + #[cfg(target_os = "linux")] + #[test] + fn wayland_drop_notice_recommends_import_or_xwayland() { + use std::ffi::OsStr; + let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap(); + assert!(warning.contains("File > Import") && warning.contains("XWayland")); + assert!(super::wayland_drop_notice(None, None).is_none()); + assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none()); + assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none()); + } + /// The window's device never asks for more than the adapter offers (#198). #[test] fn device_limits_fit_the_adapter() { 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 4/5] 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() From 6bde4d7590b24b7b47ebd47cb51707e8e353e1ad Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:38:13 +0400 Subject: [PATCH 5/5] ui: import native file drops with drop-target hint (lib.rs) --- crates/ui-egui/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 01a02881..e6bb201a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1471,6 +1471,8 @@ impl EffectcraftApp { header::show(self, ui, header); let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0)); self.dock_area(ui, body); + #[cfg(not(target_arch = "wasm32"))] + prefs_live::dropped_files(self, ui, full); panels::precomp::mini_flowchart(self, &ctx); panels::home::capture_thumbnail(self); panels::dialogs::show(self, &ctx);