diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 8c4532eb..03b3a1ac 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1407,14 +1407,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(panel_clip); let content_h = y + scroll.offset - body.min.y; scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); - // Drop effects here. + // Drop effects and animation presets here. if let Some(payload) = egui::DragAndDrop::payload::(&ctx) && ui.rect_contains_pointer(rect) - && let crate::panels::DragPayload::Effect(e) = payload.as_ref() + && let Some((id, params)) = match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)), + _ => None, + } { p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); if ctx.input(|i| i.pointer.any_released()) { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [layer.id.0]}))); + actions.push((id.into(), params)); egui::DragAndDrop::clear_payload(&ctx); } } diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 1d30cdcb..d2058fbe 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -2,8 +2,8 @@ //! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an //! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition //! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the -//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies -//! it there. +//! selected layers; dragging an effect or an animation preset onto a layer (timeline, viewer, +//! Effect Controls) applies it there. use crate::i18n::tr; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; @@ -11,7 +11,7 @@ use serde_json::json; use std::sync::Arc; use crate::icons::{self, Icon}; -use crate::panels::DragPayload; +use crate::panels::{DragPayload, PresetRef}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -108,10 +108,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: & enum Apply { Effect(String), - TextPreset(String), - /// A built-in animation preset (Transitions…) by id. - Builtin(String), - PresetFile(String), + /// A text, built-in (Transitions…) or user file animation preset. + Preset(PresetRef), } /// A folder row; returns whether it is open. @@ -216,7 +214,7 @@ fn effect_row( } } -/// A preset row (no drag; double-click applies). +/// A preset row: double-click applies to the selected layers; drag it onto a layer to apply it there. #[allow(clippy::too_many_arguments)] fn preset_row( app: &mut EffectcraftApp, @@ -227,7 +225,7 @@ fn preset_row( name: &str, auto_id: &str, query: &str, - on_apply: impl FnOnce() -> Apply, + preset: PresetRef, apply: &mut Option, ) { let t = app.tokens; @@ -236,15 +234,18 @@ fn preset_row( if r.max.y < list.min.y || r.min.y > list.max.y { return; } - let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click()); + let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click_and_drag()); if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim); highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t); app.auto.add(auto_id, r, name); + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), DragPayload::Preset { preset: preset.clone(), name: name.to_string() }); + } if resp.double_clicked() { - *apply = Some(on_apply()); + *apply = Some(Apply::Preset(preset)); } } @@ -395,7 +396,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) { for (id, name) in text { - preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, || Apply::TextPreset(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, PresetRef::Text(id.clone()), &mut apply); } } for cat in &categories { @@ -403,7 +404,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) { - preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, || Apply::Builtin(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, PresetRef::Builtin(id.clone()), &mut apply); } } if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) { @@ -417,7 +418,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { &f.name, &format!("effects.preset.file.{}", f.name), &query, - || Apply::PresetFile(f.path.clone()), + PresetRef::File(f.path.clone()), &mut apply, ); } @@ -455,10 +456,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let content = y + scroll.offset - list.min.y; scroll.end(ui, &mut app.auto, "effects.scroll", content, &t); - if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::(&ctx).as_deref() - && let Some(pos) = ctx.pointer_hover_pos() + if let Some(name) = egui::DragAndDrop::payload::(&ctx).and_then(|p| match p.as_ref() { + DragPayload::Effect(id) => Some(effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default()), + DragPayload::Preset { name, .. } => Some(name.clone()), + _ => None, + }) && let Some(pos) = ctx.pointer_hover_pos() { - let name = effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default(); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-fx"))); let g = painter.layout_no_wrap(name, Tokens::ui(12.0), t.text); let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0)); @@ -473,9 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let (cmd, params) = match &apply { Apply::Effect(id) => ("effect.apply", json!({"effect": id})), - Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})), - Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})), - Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})), + Apply::Preset(preset) => preset.command(), }; match app.session.execute(cmd, params) { Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index cedea209..3aa9a19c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -76,12 +76,49 @@ pub enum DragPayload { Item(u64), /// An effect id from Effects & Presets. Effect(String), + /// An animation preset from Effects & Presets, with its display name for the drag ghost. + Preset { preset: PresetRef, name: String }, /// A property dragged from the timeline (to the Essential Graphics panel). Property { layer: u64, prop: u64 }, /// Files from the Media Browser (import; into the timeline: import and add). Files(Vec), } +/// An animation preset in Effects & Presets. +#[derive(Clone, Debug, PartialEq)] +pub enum PresetRef { + /// A text animation preset id (`layer.applyTextPreset`). + Text(String), + /// A built-in animation preset id, e.g. a transition (`anim.applyBuiltinPreset`). + Builtin(String), + /// A user `.ecpreset` file path (`anim.applyPreset`). + File(String), +} + +impl PresetRef { + /// The command and params that apply the preset to the selected layers. + pub fn command(&self) -> (&'static str, serde_json::Value) { + match self { + PresetRef::Text(id) => ("layer.applyTextPreset", serde_json::json!({"preset": id})), + PresetRef::Builtin(id) => ("anim.applyBuiltinPreset", serde_json::json!({"preset": id})), + PresetRef::File(path) => ("anim.applyPreset", serde_json::json!({"path": path})), + } + } + + /// The command and params that apply the preset to `layer` (a drop target). + pub fn apply_to(&self, layer: u64) -> (&'static str, serde_json::Value) { + let (id, mut params) = self.command(); + if let Some(obj) = params.as_object_mut() { + // `layer.applyTextPreset` takes one `layer`; the animation preset commands take `layers`. + match self { + PresetRef::Text(_) => obj.insert("layer".into(), serde_json::json!(layer)), + PresetRef::Builtin(_) | PresetRef::File(_) => obj.insert("layers".into(), serde_json::json!([layer])), + }; + } + (id, params) + } +} + /// Layers carried by a Project item drag. Dragging a selected item uses the /// complete selection, while dragging an unselected item uses only that item. /// Folders and data files are not composition layers. @@ -250,3 +287,18 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id)); } } + +#[cfg(test)] +mod preset_drop_tests { + use super::PresetRef; + use serde_json::json; + + #[test] + fn preset_drops_target_the_layer_with_each_commands_param() { + assert_eq!(PresetRef::Text("typewriter".into()).apply_to(7), ("layer.applyTextPreset", json!({"preset": "typewriter", "layer": 7}))); + assert_eq!(PresetRef::Builtin("fadeIn".into()).apply_to(7), ("anim.applyBuiltinPreset", json!({"preset": "fadeIn", "layers": [7]}))); + assert_eq!(PresetRef::File("/p/a.ecpreset".into()).apply_to(7), ("anim.applyPreset", json!({"path": "/p/a.ecpreset", "layers": [7]}))); + // Double-click applies to the selection: no layer given. + assert_eq!(PresetRef::Text("typewriter".into()).command(), ("layer.applyTextPreset", json!({"preset": "typewriter"}))); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index e9660da3..b34fbd9d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3346,7 +3346,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } - // Drop targets: effects from Effects & Presets go on the layer under the pointer. Footage and + // Drop targets: effects and animation presets from Effects & Presets go on the layer under the + // pointer. Footage and // comps from the Project panel and files from the Media Browser go where they are dropped: a // line shows the place in the stack, and over the time graph a marker shows the In point too // (Shift: at the current time). @@ -3355,7 +3356,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect)) { let released = ctx.input(|i| i.pointer.any_released()); - if let crate::panels::DragPayload::Effect(e) = payload.as_ref() { + if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) { let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); // The layer the effect would land on (its row and its open properties) is the one // that lights up, not the whole timeline. @@ -3369,7 +3370,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("timeline.dropTarget", lit, "Drop target layer"); } if released && let Some(l) = target { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))); + match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => { + let (id, params) = preset.apply_to(l.0); + actions.push((id.into(), params)); + } + _ => {} + } } } else { let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect(); @@ -3405,7 +3413,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } - crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Property { .. } => {} + crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {} } } } diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 2d42eb2c..44364a49 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -1,8 +1,8 @@ //! Dropping onto the Composition viewer: 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}`. +//! (#85), and an effect or animation preset 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}`, `effect.apply {layers}` and the preset commands. use effectcraft_engine::project::{ItemId, ItemKind}; use effectcraft_engine::render::EvalCtx; @@ -33,6 +33,15 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain }) .into_iter() .collect(), + DragPayload::Preset { preset, .. } => 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)); + } + preset.apply_to(l.id.0) + }) + .into_iter() + .collect(), 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)) { @@ -45,7 +54,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], DragPayload::Property { .. } => vec![], }; - if actions.first().is_some_and(|(id, _)| *id != "effect.apply") { + // Layer drops outline the target layer; the others outline the whole viewer. + let layer_drop = matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); + if !layer_drop && !actions.is_empty() { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } if ctx.input(|i| i.pointer.any_released()) {