diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index 8627c9c61..242f962cb 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; }