From 8ef1a28bc4c2ab0b5e136c9037afef8516432566 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 02:31:13 +0700 Subject: [PATCH] Viewer: drop Project items, files and effects onto the comp (#85, #88) The Composition viewer ignored drags from other panels. Now, as in After Effects: - A Project item dropped on the viewer becomes a layer centred where it lands (above the selected layer, like any new layer); while dragging, the viewer is outlined and the layer's frame follows the pointer. - Media Browser files do the same; files dropped from the OS file manager do too when the platform reports where the pointer is during the drag (otherwise they import as before). - An effect from Effects & Presets goes on the layer under the pointer, which is outlined while dragging. layer.addItem and layer.newModel take `position` ([x, y] comp px). file.import takes `addToComp` with `time`, `index` and `position`; mediaBrowser.import shares it (file::add_to_comp): layered files go in as their comps, the comp they were dropped in stays open, and items that can't be layers are reported in `errors`. viewer::layer_at is the hit test pick() and the drop share. Tests: ui_drag_drop.rs (project item and effect dropped on the viewer) and imported_files_go_into_the_comp_where_they_were_dropped (engine). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/file.rs | 54 +++++++++++++++++-- crates/engine/src/commands/layer.rs | 35 +++++++++++- crates/engine/src/commands/model3d.rs | 6 ++- crates/engine/src/commands/panels_cmds.rs | 22 +++----- crates/engine/src/tests.rs | 28 ++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/viewer.rs | 12 ++++- crates/ui-egui/src/panels/viewer_drop.rs | 66 +++++++++++++++++++++++ crates/ui-egui/src/prefs_live.rs | 7 ++- crates/ui-egui/tests/ui_drag_drop.rs | 43 ++++++++++++++- 10 files changed, 248 insertions(+), 26 deletions(-) create mode 100644 crates/ui-egui/src/panels/viewer_drop.rs diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index b784a34d..fd7a02b3 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -1,7 +1,7 @@ //! File menu. use effectcraft_color::Label; -use effectcraft_project::{FootageKind, ItemKind, Project}; +use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, str_p}; @@ -101,6 +101,54 @@ fn revert(s: &mut Session, _: &Value) -> Result { open(s, &json!({"path": path})) } +/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they +/// also become layers of the active comp ([`add_to_comp`]). +fn import_cmd(s: &mut Session, p: &Value) -> Result { + let target = s.active_comp_id(); + let mut r = import(s, p)?; + let errors = add_to_comp(s, &r, target, p); + if !errors.is_empty() + && let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut) + { + e.extend(errors.into_iter().map(Value::from)); + } + Ok(r) +} + +/// With `addToComp`, put what an import (its result `r`) made into `target`, the comp that was +/// active before it, one layer under the other, at the drop's `time`, `index` and `position` +/// (as in `layer.addItem`): the comps of layered files (their layers' footage stays in the +/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added. +pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: &Value) -> Vec { + let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] }; + let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); + let comps = ids("comps"); + let in_comps: std::collections::HashSet = comps + .iter() + .filter_map(|c| s.project.comp(ItemId(*c))) + .flat_map(|c| c.layers.iter()) + .filter_map(|l| match l.source { + LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0), + _ => None, + }) + .collect(); + let add: Vec = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect(); + let mut errors = vec![]; + for (k, item) in add.into_iter().enumerate() { + let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")}); + if let Some(i) = p.get("index").and_then(Value::as_u64) { + params["index"] = json!(i.saturating_add(k as u64)); + } + if let Err(e) = s.execute("layer.addItem", params) { + errors.push(e.to_string()); + } + } + if s.active_comp_id() != Some(cid) { + s.open_comp(cid); + } + errors +} + pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let paths: Vec = match p.get("paths").or(p.get("path")) { Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(), @@ -528,9 +576,9 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}", + "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem)}", always, - import + import_cmd ), cmd!( "file.projectSettings", diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 58d35fec..01f48962 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -39,6 +39,32 @@ pub(crate) fn index_p(p: &Value, cmd: &str) -> Result> { } } +/// A `position` parameter: `[x, y]` in comp pixels (finite). +pub(crate) fn position_p(p: &Value, cmd: &str) -> Result> { + match p.get("position") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_array() + .and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])) + .filter(|c| c.iter().all(|x| x.is_finite())) + .map(Some) + .ok_or_else(|| bad(cmd, "position: [x, y] in comp pixels")), + } +} + +/// Put a new layer's Position at `pos` (comp pixels). +pub(crate) fn place(l: &mut Layer, pos: Option<[f64; 2]>) { + if let Some([x, y]) = pos + && let Some(pr) = l.props.prop_mut("transform/position") + { + let z = match pr.value { + KV::Vec3(v) => v[2], + _ => 0.0, + }; + pr.value = KV::Vec3([x, y, z]); + } +} + /// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer. pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option) -> Result { // Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels). @@ -277,13 +303,17 @@ fn add_item(s: &mut Session, p: &Value) -> Result { return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData")); } ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => { - return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index")})); + return super::model3d::new_model( + s, + &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}), + ); } ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)), ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None), ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")), }; let index = index_p(p, "layer.addItem")?; + let position = position_p(p, "layer.addItem")?; let fr = comp.frame_rate; // Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time). let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() }; @@ -293,6 +323,7 @@ fn add_item(s: &mut Session, p: &Value) -> Result { l.start_time = start; l.in_point = start; l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration()); + place(&mut l, position); insert_layer_at(proj, st, cid, l, index) })?; Ok(json!({"layer": id.0})) @@ -1267,7 +1298,7 @@ pub fn specs() -> Vec { "Add Footage to Comp", ["File"], Some("Cmd+/"), - "{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), duration? (s, for a still)}", + "{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still)}", has_comp, add_item ), diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 5cd3b86d..ff2ff1ed 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -8,7 +8,7 @@ use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Nod use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::layer::{color_p, index_p, insert_layer, insert_layer_at}; +use super::layer::{color_p, index_p, insert_layer, insert_layer_at, place, position_p}; use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; @@ -120,6 +120,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { let fr = comp.frame_rate; let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO)); let index = index_p(p, "layer.newModel")?; + let position = position_p(p, "layer.newModel")?; let id = s.edit("New 3D Model Layer", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None); let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips); @@ -129,6 +130,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { l.start_time = start; l.in_point = start; l.out_point = comp.duration.max(start + fr.frame_duration()); + place(&mut l, position); insert_layer_at(proj, st, cid, l, index) })?; Ok(json!({"layer": id.0, "item": item.0})) @@ -350,7 +352,7 @@ pub fn specs() -> Vec { "3D Model Layer", [], None, - "{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position)}", + "{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}", has_comp, new_model ), diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/commands/panels_cmds.rs index d7179b3e..9133a362 100644 --- a/crates/engine/src/commands/panels_cmds.rs +++ b/crates/engine/src/commands/panels_cmds.rs @@ -104,20 +104,14 @@ fn browser_import(s: &mut Session, p: &Value) -> Result { if !browser(s)?.fetch(&list, p) { return Ok(json!({"pending": true, "paths": list})); } - let r = s.execute("file.import", json!({"paths": paths}))?; - // Add to the active comp (drag into the timeline). - if b_p(p, "addToComp").unwrap_or(false) - && s.active_comp_id().is_some() - && let Some(items) = r.get("items").and_then(Value::as_array) + let target = s.active_comp_id(); + let mut r = s.execute("file.import", json!({"paths": paths}))?; + // Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped. + let errors = super::file::add_to_comp(s, &r, target, p); + if !errors.is_empty() + && let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut) { - // Where a drop put them (`time`, `index`): one under the other. - for (k, it) in items.iter().enumerate() { - let mut add = json!({"item": it, "time": p.get("time")}); - if let Some(i) = p.get("index").and_then(Value::as_u64) { - add["index"] = json!(i.saturating_add(k as u64)); - } - s.execute("layer.addItem", add)?; - } + e.extend(errors.into_iter().map(Value::from)); } Ok(r) } @@ -243,7 +237,7 @@ pub fn specs() -> Vec { "Import", [], None, - "{paths, addToComp?, time?, index? (where the layers go, as in layer.addItem)}", + "{paths, addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}", browser_enabled, browser_import ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 19a13514..93e67a4a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -68,6 +68,34 @@ fn add_item_at_a_stack_index_and_time() { assert!(s.execute("layer.addItem", json!({"item": clip, "index": 0})).is_err(), "1-based"); } +/// Files dropped on the Timeline or the Composition viewer (`file.import {addToComp}`) become +/// layers of the comp that was active, one under the other, where they were dropped (#85, #89). +#[test] +fn imported_files_go_into_the_comp_where_they_were_dropped() { + use effectcraft_project::{Footage, FootageKind}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() }) + } + } + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Bg"})).unwrap(); + let params = json!({"paths": ["/drop/a.png", "/drop/b.png"], "addToComp": true, "position": [40.0, 50.0], "index": 1, "time": 1.0}); + s.execute("file.import", params).unwrap(); + let comp = s.active_comp().unwrap(); + assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["a.png", "b.png", "Bg"]); + for l in &comp.layers[..2] { + assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0]))); + assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0)); + } + // Without `addToComp` they only import. + s.execute("file.import", json!({"paths": ["/drop/c.png"]})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 3); + assert!(s.execute("layer.addItem", json!({"item": "a.png", "position": [1.0]})).is_err(), "position needs x and y"); +} + /// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103). #[test] fn audio_lock_and_shy_keep_cached_layers() { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 20717ee5..b6cae1a0 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -49,6 +49,7 @@ pub mod timeline; pub mod tracker; pub mod unsaved; pub mod viewer; +pub mod viewer_drop; pub mod viewer_overlays; pub mod viewer_text; pub mod viewer_tools; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index ddfb0a72..56d180ac 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -238,7 +238,7 @@ pub(crate) fn l2c(ctx: &EvalCtx, layer: &Layer) -> (Mat3, f64) { } /// Layer → comp matrix and its bounds quad (in comp pixels). -fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> { +pub(crate) fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> { let b = effectcraft_engine::render::content_bounds(ctx, layer)?; let (m, _) = l2c(ctx, layer); let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| { @@ -881,6 +881,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } + // Project items, files and effects dropped on the viewer. + super::viewer_drop::show(app, ui, &painter, &map, &ectx); + // Interaction. let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag()); let menu_hits_id = egui::Id::new(("viewer-menu-hits", cid.0)); @@ -1733,9 +1736,14 @@ pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { + selectable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))) +} + /// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer. pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { - let hit = selectable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))).map(|l| l.id)?; + let hit = layer_at(ectx, cpt).map(|l| l.id)?; if toggle { let _ = app.session.execute("layer.select", json!({"layers": [hit.0], "toggle": true})); } else if !app.session.state.selected_layers.contains(&hit) { diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs new file mode 100644 index 00000000..eeb50cca --- /dev/null +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -0,0 +1,66 @@ +//! Dropping onto the Composition viewer, as in After Effects: footage, comps and solids from the +//! Project panel and files from the Media Browser become layers centred where they are dropped +//! (#85), and an effect from Effects & Presets goes on the layer under the pointer, outlined +//! while it is dragged (#88). Agents do the same with `layer.addItem {position}`, +//! `mediaBrowser.import {position}` and `effect.apply {layers}`. + +use effectcraft_engine::project::{ItemId, ItemKind}; +use effectcraft_engine::render::EvalCtx; +use egui::{Rect, Stroke, StrokeKind, vec2}; +use serde_json::{Value, json}; + +use super::DragPayload; +use super::viewer::{ViewerMap, layer_at, layer_quad}; +use crate::EffectcraftApp; + +/// While something is dragged over the viewer, show where it goes; drop it on release. +pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) { + let ctx = ui.ctx().clone(); + let Some(payload) = egui::DragAndDrop::payload::(&ctx) else { return }; + if matches!(*payload, DragPayload::Property { .. }) { + return; + } + let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return }; + let stroke = Stroke::new(2.0, app.tokens.accent); + let at = map.to_comp(ptr); + let action: Option<(&str, Value)> = match payload.as_ref() { + DragPayload::Effect(effect) => layer_at(ectx, at).map(|l| { + if let Some((_, q, _)) = layer_quad(ectx, l) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) + }), + DragPayload::Item(item) => { + // The layer's frame, centred on the pointer. + if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { + painter.rect_stroke(Rect::from_center_size(ptr, vec2(w, h) * map.zoom), 0.0, Stroke::new(1.0, app.tokens.accent), StrokeKind::Middle); + } + Some(("layer.addItem", json!({"item": item, "position": at}))) + } + DragPayload::Files(paths) => Some(("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))), + DragPayload::Property { .. } => None, + }; + if action.as_ref().is_some_and(|(id, _)| *id != "effect.apply") { + painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); + } + if ctx.input(|i| i.pointer.any_released()) { + egui::DragAndDrop::clear_payload(&ctx); + if let Some((id, params)) = action + && let Err(e) = crate::menus::invoke(app, &ctx, id, params) + { + app.ui.status = e; + } + } +} + +/// The size in comp pixels of a layer made from an item of `kind` (`None`: nothing to see). +fn frame_size(kind: &ItemKind, comp_pixel_aspect: f64) -> Option<[f32; 2]> { + let (w, h, pixel_aspect) = match kind { + ItemKind::Comp(c) => (c.width, c.height, c.pixel_aspect), + ItemKind::Footage(f) if f.has_video => (f.width, f.height, f.pixel_aspect), + ItemKind::Solid(s) => (s.width, s.height, s.pixel_aspect), + _ => return None, + }; + let stretch = if comp_pixel_aspect > 0.0 && pixel_aspect > 0.0 { pixel_aspect / comp_pixel_aspect } else { 1.0 }; + (w > 0 && h > 0).then_some([(w as f64 * stretch) as f32, h as f32]) +} diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index 9dd4d639..14e7b7cc 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -18,6 +18,8 @@ pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) { } /// Files dropped on the window are imported (layered files as Default Drag Import As says). +/// 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()); @@ -34,7 +36,10 @@ fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) { app.ui.status = e; } if !rest.is_empty() { - let params = json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as()}); + 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}); if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) { app.ui.status = e; } diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 08d4ac0f..1e25bf1b 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -1,9 +1,14 @@ -//! Dragging Project items into the Timeline (egui_kittest, real pointer drags): they land where -//! they are dropped, between layers and, over the time graph, starting there (#89). +//! Drag and drop between panels (egui_kittest, real pointer drags), as in After Effects: Project +//! items land in the Timeline where they are dropped, between layers and, over the time graph, +//! starting there (#89); dropped on the Composition viewer they are centred where they land +//! (#85); an effect dropped on the viewer goes on the layer under the pointer (#88). use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value as KV; use effectcraft_engine::time::Tick; use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::panels::viewer::{comp_to_screen, last_fit}; use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; use serde_json::json; @@ -97,3 +102,37 @@ fn project_items_land_where_they_are_dropped_in_the_timeline() { assert_eq!(stack(&h), ["Top", "Middle", "Bottom", "Clip"]); assert!((in_point(&h, 3) - 1.0).abs() < 1e-9, "Shift starts it at the current time"); } + +/// #85: a Project item dropped on the Composition viewer becomes a layer centred where it was +/// dropped (above the selected layer, like any new layer). +#[test] +fn project_items_dropped_on_the_viewer_land_under_the_pointer() { + let (mut h, clip) = harness(); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); + drag(&mut h, item, to, Modifiers::NONE); + assert_eq!(stack(&h), ["Clip", "Top", "Middle", "Bottom"]); + let comp = h.state().session.active_comp().unwrap(); + let Some(KV::Vec3(p)) = comp.layers[0].props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; + // One screen point is up to 1/zoom comp pixels. + 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:?}"); +} + +/// #88: an effect dragged from Effects & Presets onto the viewer goes on the layer under the +/// pointer, not the selected one. +#[test] +fn effects_dropped_on_the_viewer_go_on_the_layer_under_the_pointer() { + let (mut h, _) = harness(); + let (top, middle) = (layer_id(&h, "Top"), layer_id(&h, "Middle")); + h.state_mut().session.execute("layer.select", json!({"layers": [middle]})).unwrap(); + h.state_mut().show_panel(PanelKind::EffectsPresets); + h.state_mut().ui.effects_search = "Gaussian Blur".into(); + h.run_steps(3); + let fx = rect(&h, "effects.item.ec.blur.gaussian").center(); + let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + drag(&mut h, fx, to, Modifiers::NONE); + let comp = h.state().session.active_comp().unwrap(); + let effects = |id: u64| comp.layer(effectcraft_engine::project::LayerId(id)).unwrap().effects().map_or(0, |fx| fx.groups().count()); + assert_eq!((effects(top), effects(middle)), (1, 0), "on Top, which is under the pointer"); +}