Merge pull request #1317

# Conflicts:
#	crates/ui-egui/src/panels/effects_presets.rs
#	crates/ui-egui/src/panels/timeline.rs
This commit is contained in:
Brandon Thomas
2026-10-18 11:12:44 -04:00
5 changed files with 107 additions and 31 deletions
+7 -3
View File
@@ -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::<crate::panels::DragPayload>(&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);
}
}
+21 -20
View File
@@ -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<Apply>,
) {
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::<DragPayload>(&ctx).as_deref()
&& let Some(pos) = ctx.pointer_hover_pos()
if let Some(name) = egui::DragAndDrop::payload::<DragPayload>(&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(),
+52
View File
@@ -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<String>),
}
/// 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"})));
}
}
+12 -4
View File
@@ -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 { .. } => {}
}
}
}
+15 -4
View File
@@ -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()) {