simple_editor\ui/
effects_ui.rs

1//! Effects panel. Top: the CATALOGUE as a thumbnail grid — one ~96x54 card per `EffectKind` showing the
2//! effect applied to a stock image with its name underneath, grouped by `EffectKind::category()` under
3//! collapsible headers, with a search box. Thumbnails arrive through `set_thumbnail(kind, id, size)`,
4//! which the app calls after rendering them on the GPU; without a GL context a card still shows its name
5//! on a neutral tile (never blank) — an effect with `EffectKind::applies_to_audio()` instead gets a
6//! solid green tile with a music-note glyph, since it has no picture to render. The catalogue is filtered
7//! to what the first selected clip can use (audio clip -> audio effects only, and vice versa); with
8//! nothing selected it shows everything. Clicking a card adds the effect to every eligible selected
9//! clip, right-clicking opens its quick actions (selected clips / every clip on the track), and only a
10//! deliberate press-and-move makes it a `DragPayload::Effect` drag source (`ui::drag_source`).
11//! Below: the FIRST selected clip's effect stack, in order: for each effect a header row (enabled
12//! checkbox, name, mask button, "Edit shader…" for a custom shader, copy/paste params, painted
13//! reorder and remove buttons) and its parameters from
14//! `effect.specs()`: a "From … for …" row giving the effect a window inside the clip (0 length = to the
15//! end of it, so an effect covers the whole clip until the user says otherwise), then
16//! label + DragValue (range from ParamSpec, speed ≈ (max-min)/200) — or a CHECKBOX when
17//! `EffectKind::is_bool_param` — + a diamond keyframe toggle at clip-local playhead time
18//! (Animated::toggle_key / set_at, highlighted when a key exists) + a button to clear them. Tint shows a
19//! colour button bound to R/G/B as well.
20//! An effect with a mask gets the inspector's mask grid inline (shape / position / radius / feather /
21//! invert …) plus a delete button. A clip that renders from a REAL node graph (`Clip::uses_graph`) greys
22//! the stack out — the graph is what the renderer evaluates, so stack edits would be invisible — and
23//! offers "Unlink" to get back to the list.
24//! Every change → undo once per gesture (same `edit_start` rule as the inspector).
25
26use crate::model::{Animated, ClipKind, Effect, EffectKind, Id, Mask, Project};
27use crate::settings::MotionPreset;
28use crate::theme::Palette;
29use crate::ui::tools::{glyph_text_button, Dir, Glyph};
30use crate::ui::{mask_grid, DragPayload, Gesture};
31use eframe::egui::{self, Button, DragValue, Grid, Response, StrokeKind};
32use std::cell::RefCell;
33use std::collections::HashMap;
34
35/// Card size of one catalogue thumbnail (the name is drawn under it).
36pub const CARD: (f32, f32) = (96.0, 54.0);
37
38thread_local! {
39    // ponytail: thread_local hand-off because show() can't reach Settings — the app polls
40    // take_pending_motion() each frame and stores the preset. Upgrade: pass an EffectsState if the
41    // signature is ever allowed to grow.
42    static PENDING_MOTION: RefCell<Option<MotionPreset>> = const { RefCell::new(None) };
43    /// GPU-rendered catalogue thumbnails, uploaded by the app (empty without a GL context).
44    static THUMBS: RefCell<HashMap<EffectKind, (egui::TextureId, [u32; 2])>> = RefCell::new(HashMap::new());
45    /// One effect's parameters on the panel's private clipboard (Copy / Paste on the stack rows).
46    static PARAM_CLIP: RefCell<Option<Effect>> = const { RefCell::new(None) };
47}
48
49/// A motion preset captured via "Save as motion preset…" waiting for the app to store it in Settings.
50pub fn take_pending_motion() -> Option<MotionPreset> {
51    PENDING_MOTION.with(|p| p.borrow_mut().take())
52}
53
54/// The app hands a rendered catalogue thumbnail to the panel (texture must stay alive while shown).
55pub fn set_thumbnail(kind: EffectKind, texture: egui::TextureId, size: [u32; 2]) {
56    THUMBS.with(|t| t.borrow_mut().insert(kind, (texture, size)));
57}
58
59/// Drop every catalogue thumbnail (stock image changed / GL context lost).
60pub fn clear_thumbnails() {
61    THUMBS.with(|t| t.borrow_mut().clear());
62}
63
64/// How many thumbnails the panel currently holds.
65pub fn thumbnail_count() -> usize {
66    THUMBS.with(|t| t.borrow().len())
67}
68
69pub(crate) fn thumbnail(kind: EffectKind) -> Option<(egui::TextureId, [u32; 2])> {
70    THUMBS.with(|t| t.borrow().get(&kind).copied())
71}
72
73/// Catalogue categories in `EffectKind::ALL` order (first appearance wins), so the grid is stable.
74fn categories() -> Vec<&'static str> {
75    let mut v: Vec<&'static str> = Vec::new();
76    for k in EffectKind::ALL {
77        if !v.contains(&k.category()) {
78            v.push(k.category());
79        }
80    }
81    v
82}
83
84fn matches(kind: EffectKind, q: &str) -> bool {
85    if q.is_empty() {
86        return true;
87    }
88    let q = q.to_lowercase();
89    kind.name().to_lowercase().contains(&q) || kind.category().to_lowercase().contains(&q)
90}
91
92/// Whether `kind` belongs in the catalogue for the selected clip: `audio` is the first selected clip's
93/// "is it an audio clip" (None = nothing selected, so nothing is filtered out).
94fn fits_selection(kind: EffectKind, audio: Option<bool>) -> bool {
95    audio.map_or(true, |a| kind.applies_to_audio() == a)
96}
97
98/// One catalogue card: the thumbnail (or a neutral named tile, or a green tile + note for an audio
99/// effect) with the effect name underneath. A plain clickable / right-clickable widget that only
100/// becomes a `DragPayload::Effect` source on a deliberate drag (`ui::drag_source`).
101fn effect_card(ui: &mut egui::Ui, kind: EffectKind, palette: &Palette) -> Response {
102    let font = egui::TextStyle::Small.resolve(ui.style());
103    let name_h = ui.text_style_height(&egui::TextStyle::Small);
104    let id = ui.id().with(("fx_card", kind));
105    let r = crate::ui::drag_source(ui, id, DragPayload::Effect(kind), |ui| {
106        let (rect, _) = ui.allocate_exact_size(egui::vec2(CARD.0, CARD.1 + name_h + 2.0), egui::Sense::hover());
107        let tile = egui::Rect::from_min_size(rect.min, egui::vec2(CARD.0, CARD.1));
108        let p = ui.painter();
109        if kind.applies_to_audio() {
110            // no picture to render for an audio effect: a solid tile + note reads at a glance
111            p.rect_filled(tile, 2.0, palette.clip_audio);
112            crate::ui::tools::draw_glyph(p, tile, crate::ui::tools::Glyph::MusicNote, egui::Color32::WHITE);
113        } else {
114            match thumbnail(kind) {
115                Some((tex, _)) => {
116                    p.image(
117                        tex,
118                        tile,
119                        egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
120                        egui::Color32::WHITE,
121                    );
122                }
123                None => {
124                    // no GL / not rendered yet: a neutral tile that still names the effect
125                    p.rect_filled(tile, 2.0, palette.header);
126                    let g = p.layout(kind.name().to_string(), font.clone(), palette.text_dim, CARD.0 - 8.0);
127                    p.galley(tile.center() - g.size() / 2.0, g, palette.text_dim);
128                }
129            }
130        }
131        let border = if ui.rect_contains_pointer(tile) { palette.accent } else { palette.border };
132        p.rect_stroke(tile, 2.0, egui::Stroke::new(1.0, border), StrokeKind::Inside);
133        let label = p.layout_no_wrap(kind.name().to_string(), font, palette.text);
134        let lx = (rect.left() + (CARD.0 - label.size().x) / 2.0).max(rect.left());
135        p.with_clip_rect(egui::Rect::from_min_max(egui::pos2(rect.left(), tile.bottom()), rect.max)).galley(
136            egui::pos2(lx, tile.bottom() + 2.0),
137            label,
138            palette.text,
139        );
140    });
141    r.on_hover_text(format!("{} · {}", kind.name(), kind.category()))
142}
143
144/// Test-only registry of widget rects so headless tests can click real widgets without pixel-guessing.
145#[cfg(test)]
146pub(crate) mod test_rects {
147    use eframe::egui::Rect;
148    use std::cell::RefCell;
149    thread_local! {
150        static RECTS: RefCell<Vec<(String, Rect)>> = const { RefCell::new(Vec::new()) };
151    }
152    pub fn clear() {
153        RECTS.with(|r| r.borrow_mut().clear());
154    }
155    pub fn push(name: String, rect: Rect) {
156        RECTS.with(|r| r.borrow_mut().push((name, rect)));
157    }
158    pub fn get(name: &str) -> Option<Rect> {
159        RECTS.with(|r| r.borrow().iter().rev().find(|(n, _)| n == name).map(|(_, rect)| *rect))
160    }
161}
162
163fn key_buttons(ui: &mut egui::Ui, a: &mut Animated, lt: f64, palette: &Palette, g: &mut Gesture, _at: (usize, usize)) {
164    let r = crate::ui::tools::icon_button(
165        ui,
166        palette,
167        ui.id().with(("kf", _at)),
168        crate::ui::tools::Glyph::Diamond,
169        "Toggle keyframe at playhead",
170        a.has_key_at(lt),
171    );
172    #[cfg(test)]
173    test_rects::push(format!("kf{}_{}", _at.0, _at.1), r.rect);
174    if r.clicked() {
175        a.toggle_key(lt);
176        g.click();
177    }
178    if a.is_animated()
179        && crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::Cross, "keys")
180            .on_hover_text("Remove all keyframes")
181            .clicked()
182    {
183        a.clear_keys(lt);
184        g.click();
185    }
186}
187
188/// Unit suffix for a wobble parameter.
189fn wobble_suffix(name: &str) -> &'static str {
190    match name {
191        "Amplitude X" | "Amplitude Y" => " px",
192        "Roll" | "Yaw" | "Pitch" => "°",
193        "Frequency" => " Hz",
194        _ => "",
195    }
196}
197
198#[derive(Default)]
199pub struct EffectsResponse {
200    pub edited: bool,
201    /// Index (into the first selected clip's stack) of the effect whose mask the user wants to draw —
202    /// the app switches to the mask tool and points the viewport at it.
203    pub mask_for: Option<usize>,
204    /// "Node editor" was clicked: the app opens the node pane for the selected clip.
205    pub open_nodes: bool,
206    /// Index of the `EffectKind::Shader` effect whose GLSL the user wants to edit (app opens the window).
207    pub edit_shader: Option<usize>,
208}
209
210/// Which clips a catalogue pick lands on: a click (and the first menu entry) means the selection, the
211/// card's quick-action menu can widen it to the whole track the selection sits on.
212#[derive(Clone, Copy)]
213enum Scope {
214    Selection,
215    Track,
216}
217
218/// The catalogue grid: search box + collapsible category sections of thumbnail cards, filtered to what
219/// the selected clip (`audio`) can use. Returns the effect a card click or quick-action menu asked for.
220fn catalogue(ui: &mut egui::Ui, palette: &Palette, audio: Option<bool>) -> Option<(EffectKind, Scope)> {
221    let mut add = None;
222    let has_sel = audio.is_some();
223    let search_id = ui.id().with("fx_search");
224    let mut q: String = ui.ctx().data_mut(|d| d.get_temp(search_id).unwrap_or_default());
225    ui.horizontal(|ui| {
226        ui.strong("Effects");
227        crate::ui::tools::glyph_label(ui, crate::ui::tools::Glyph::Zoom, palette.text_dim);
228        ui.add(egui::TextEdit::singleline(&mut q).hint_text("search").desired_width(f32::INFINITY));
229    });
230    let hits: Vec<EffectKind> =
231        EffectKind::ALL.into_iter().filter(|&k| matches(k, &q) && fits_selection(k, audio)).collect();
232    if hits.is_empty() {
233        ui.weak(if audio == Some(true) { "No audio effects yet" } else { "No effect matches" });
234    }
235    for cat in categories() {
236        let in_cat: Vec<EffectKind> = hits.iter().copied().filter(|k| k.category() == cat).collect();
237        if in_cat.is_empty() {
238            continue;
239        }
240        egui::CollapsingHeader::new(cat).id_salt(("fx_cat", cat)).default_open(true).show(ui, |ui| {
241            ui.horizontal_wrapped(|ui| {
242                for kind in in_cat {
243                    let r = effect_card(ui, kind, palette);
244                    #[cfg(test)]
245                    test_rects::push(format!("card_{}", kind.name()), r.rect);
246                    if r.clicked() {
247                        add = Some((kind, Scope::Selection));
248                    }
249                    r.context_menu(|ui| {
250                        if ui.add_enabled(has_sel, Button::new("Add to selected clip(s)")).clicked() {
251                            add = Some((kind, Scope::Selection));
252                            ui.close();
253                        }
254                        if ui.add_enabled(has_sel, Button::new("Add to all clips on this track")).clicked() {
255                            add = Some((kind, Scope::Track));
256                            ui.close();
257                        }
258                    });
259                }
260            });
261        });
262    }
263    ui.ctx().data_mut(|d| d.insert_temp(search_id, q));
264    add
265}
266
267pub fn show(
268    ui: &mut egui::Ui,
269    project: &mut Project,
270    selection: &[Id],
271    playhead: f64,
272    palette: &Palette,
273    undo: &mut dyn FnMut(&Project),
274) -> EffectsResponse {
275    let mut out = EffectsResponse::default();
276    #[cfg(test)]
277    test_rects::clear();
278
279    // first selected clip that still exists — drives both the catalogue's audio-aware filter and the
280    // stack panel below
281    let first_id = selection.iter().find(|&&id| project.clip(id).is_some()).copied();
282    let audio_sel = first_id.and_then(|id| project.clip(id)).map(|c| c.kind == ClipKind::Audio);
283
284    // ---- catalogue ----
285    if let Some((kind, scope)) = catalogue(ui, palette, audio_sel) {
286        // "all clips on this track" = the track the first selected clip sits on
287        let track: Vec<Id> = match scope {
288            Scope::Selection => Vec::new(),
289            Scope::Track => first_id
290                .and_then(|id| project.track_of(id))
291                .map(|ti| project.tracks[ti].clips.iter().map(|c| c.id).collect())
292                .unwrap_or_default(),
293        };
294        // a clip that renders from a real node graph would show nothing of what is pushed on its linear
295        // stack, so those clips are skipped (the stack below is greyed out for the same reason). A bare
296        // Input→Output graph is not one — see Clip::uses_graph.
297        // each selected clip is checked on its own kind, not just the first (a mixed video+audio
298        // selection can still get an eligible effect on every clip it applies to).
299        let targets: Vec<Id> = match scope {
300            Scope::Selection => selection,
301            Scope::Track => track.as_slice(),
302        }
303        .iter()
304        .copied()
305        .filter(|&id| {
306            let Some(c) = project.clip(id) else { return false };
307            (c.kind == ClipKind::Audio) == kind.applies_to_audio() && !c.uses_graph()
308        })
309        .collect();
310        if !targets.is_empty() {
311            undo(project);
312            for id in targets {
313                if let Some(c) = project.clip_mut(id) {
314                    c.effects.push(Effect::new(kind));
315                }
316            }
317            out.edited = true;
318        }
319    }
320
321    // ---- stack of the first selected clip ----
322    let Some(id) = first_id else {
323        ui.separator();
324        ui.label("Select a clip");
325        return out;
326    };
327    // ponytail: edit a per-frame clone and write back (inspector pattern) so `undo` can snapshot first.
328    let orig = project.clip(id).cloned();
329    let Some(mut clip) = orig else { return out };
330    // clamped: the playhead can sit off the clip, and keys written outside [0, duration] are invisible
331    // in every editor yet still make the param "animated" forever (mixer.rs clamps the same way).
332    let lt = clip.local(playhead).clamp(0.0, clip.duration);
333    let mut g = Gesture::default();
334
335    ui.separator();
336    ui.horizontal(|ui| {
337        ui.strong(clip.name.clone());
338        let nodes = ui.small_button("Node editor").on_hover_text("Edit this clip's chain as a graph");
339        #[cfg(test)]
340        test_rects::push("nodes".into(), nodes.rect);
341        if nodes.clicked() {
342            out.open_nodes = true;
343        }
344    });
345    if clip.uses_graph() {
346        // gpu::run_chain evaluates the graph and never looks at clip.effects — show the stack, but do
347        // not let the user edit into the void; "Unlink" is the way back to it in one click
348        ui.horizontal(|ui| {
349            ui.colored_label(palette.text_dim, "Renders from its node graph.");
350            let r = ui.small_button("Unlink").on_hover_text("Back to this plain effect list (a simple chain only)");
351            #[cfg(test)]
352            test_rects::push("unlink".into(), r.rect);
353            if r.clicked() {
354                crate::ui::inspector::ask_unlink_nodes(id);
355            }
356        });
357        ui.disable();
358    }
359    let n = clip.effects.len();
360    let dur = clip.duration;
361    // a mask shapes pixels — an audio clip's filters get no mask controls at all
362    let maskable = clip.is_visual();
363    let mut remove: Option<usize> = None;
364    let mut swap: Option<(usize, usize)> = None;
365    let mut copy: Option<usize> = None;
366    let mut paste: Option<usize> = None;
367    let copied_kind = PARAM_CLIP.with(|c| c.borrow().as_ref().map(|e| e.kind));
368    for (i, fx) in clip.effects.iter_mut().enumerate() {
369        ui.horizontal(|ui| {
370            g.note(&ui.checkbox(&mut fx.enabled, ""));
371            ui.label(fx.kind.name());
372            let masked = fx.mask.is_some();
373            if maskable {
374                let mask = ui
375                    .add(Button::new(if masked { "Edit mask" } else { "Add mask" }).small())
376                    .on_hover_text("Limit this effect to a shape (drawn in the viewport)");
377                #[cfg(test)]
378                test_rects::push(format!("mask{i}"), mask.rect);
379                if mask.clicked() {
380                    if !masked {
381                        fx.mask = Some(Mask::default());
382                        g.click();
383                    }
384                    out.mask_for = Some(i);
385                }
386                if masked {
387                    let rm = crate::ui::markers_ui::x_button(ui).on_hover_text("Remove this mask");
388                    #[cfg(test)]
389                    test_rects::push(format!("maskx{i}"), rm.rect);
390                    if rm.clicked() {
391                        fx.mask = None;
392                        g.click();
393                    }
394                }
395            }
396            if fx.kind == EffectKind::Shader {
397                let ed = ui.add(Button::new("Edit shader…").small()).on_hover_text("Edit this effect's GLSL");
398                #[cfg(test)]
399                test_rects::push(format!("shader{i}"), ed.rect);
400                if ed.clicked() {
401                    out.edit_shader = Some(i);
402                }
403            }
404            let cp = glyph_text_button(ui, Glyph::Copy, "").on_hover_text("Copy these parameters");
405            #[cfg(test)]
406            test_rects::push(format!("copy{i}"), cp.rect);
407            if cp.clicked() {
408                copy = Some(i);
409            }
410            let can_paste = copied_kind == Some(fx.kind);
411            let pt = ui
412                .add_enabled_ui(can_paste, |ui| glyph_text_button(ui, Glyph::Paste, ""))
413                .inner
414                .on_hover_text("Paste copied parameters")
415                .on_disabled_hover_text("Copy the same kind of effect first");
416            #[cfg(test)]
417            test_rects::push(format!("paste{i}"), pt.rect);
418            if pt.clicked() {
419                paste = Some(i);
420            }
421            let up = ui
422                .add_enabled_ui(i > 0, |ui| glyph_text_button(ui, Glyph::Tri(Dir::Up), ""))
423                .inner
424                .on_hover_text("Move up");
425            if up.clicked() {
426                swap = Some((i, i - 1));
427            }
428            let down = ui
429                .add_enabled_ui(i + 1 < n, |ui| glyph_text_button(ui, Glyph::Tri(Dir::Down), ""))
430                .inner
431                .on_hover_text("Move down");
432            if down.clicked() {
433                swap = Some((i, i + 1));
434            }
435            let del = crate::ui::markers_ui::x_button(ui).on_hover_text("Remove this effect");
436            if del.clicked() {
437                remove = Some(i);
438            }
439            #[cfg(test)]
440            {
441                test_rects::push(format!("up{i}"), up.rect);
442                test_rects::push(format!("down{i}"), down.rect);
443                test_rects::push(format!("del{i}"), del.rect);
444            }
445        });
446        // when it runs inside the clip — 0 length means "to the end", which is what every effect that
447        // predates this row already says
448        ui.horizontal(|ui| {
449            ui.label("From");
450            let r = ui.add(
451                DragValue::new(&mut fx.start)
452                    .range(0.0..=dur)
453                    .clamp_existing_to_range(false)
454                    .speed(dur / 200.0)
455                    .suffix(" s"),
456            );
457            #[cfg(test)]
458            test_rects::push(format!("fxstart{i}"), r.rect);
459            g.note(&r);
460            ui.label("for");
461            let r = ui
462                .add(
463                    DragValue::new(&mut fx.len)
464                        .range(0.0..=dur)
465                        .clamp_existing_to_range(false)
466                        .speed(dur / 200.0)
467                        .suffix(" s"),
468                )
469                .on_hover_text("0 = to the end of the clip");
470            #[cfg(test)]
471            test_rects::push(format!("fxlen{i}"), r.rect);
472            g.note(&r);
473            if fx.len <= 0.0 {
474                ui.weak("(rest of the clip)");
475            }
476        });
477        if fx.kind == EffectKind::Tint && fx.params.len() >= 3 {
478            ui.horizontal(|ui| {
479                ui.label("Colour");
480                let mut rgb = [fx.params[0].at(lt) as u8, fx.params[1].at(lt) as u8, fx.params[2].at(lt) as u8];
481                let r = ui.color_edit_button_srgb(&mut rgb);
482                if r.changed() {
483                    for (a, v) in fx.params.iter_mut().zip(rgb) {
484                        a.set_at(lt, v as f64);
485                    }
486                }
487                g.note(&r);
488            });
489        }
490        // the effect's own mask: same grid as the inspector's clip mask, so it can be shaped without
491        // the viewport (the mask tool only ever edits clip.mask)
492        if let Some(m) = fx.mask.as_mut().filter(|_| maskable) {
493            let _r = ui.scope(|ui| mask_grid(ui, m, lt, palette, &mut g, egui::Id::new(("fx_mask", i)))).response;
494            #[cfg(test)]
495            test_rects::push(format!("maskgrid{i}"), _r.rect);
496        }
497        let kind = fx.kind;
498        Grid::new(("fx_params", i)).num_columns(2).show(ui, |ui| {
499            for (j, spec) in kind.params().iter().enumerate() {
500                let Some(a) = fx.params.get_mut(j) else { continue };
501                ui.label(spec.name);
502                ui.horizontal(|ui| {
503                    let mut v = a.at(lt);
504                    let r = if kind == EffectKind::Wobble && spec.name == "Motion" {
505                        // a named waveform reads better than 0..4 (Sine / Layered / Cubic / …)
506                        let names = crate::model::WOBBLE_MOTIONS;
507                        let cur = (v.round().clamp(0.0, (names.len() - 1) as f64)) as usize;
508                        let mut sel = cur;
509                        let inner = egui::ComboBox::from_id_salt(("wobble_motion", i))
510                            .selected_text(names[cur])
511                            .width(96.0)
512                            .show_ui(ui, |ui| {
513                                for (k, n) in names.iter().enumerate() {
514                                    ui.selectable_value(&mut sel, k, *n);
515                                }
516                            });
517                        let mut r = inner.response;
518                        if sel != cur {
519                            v = sel as f64;
520                            r.mark_changed();
521                        }
522                        r
523                    } else if kind.is_bool_param(j) {
524                        // stored as 0/1 — a checkbox is the honest widget (Flip H/V, "Show mask", …)
525                        let mut on = v >= 0.5;
526                        let r = ui.checkbox(&mut on, "");
527                        if r.changed() {
528                            v = if on { 1.0 } else { 0.0 };
529                        }
530                        r
531                    } else {
532                        // clamp_existing_to_range(false): clamping an out-of-range stored value reports
533                        // `changed()`, which would fake an edit just by drawing the panel.
534                        let mut dv = DragValue::new(&mut v)
535                            .range(spec.min..=spec.max)
536                            .clamp_existing_to_range(false)
537                            .speed((spec.max - spec.min) / 200.0);
538                        if kind == EffectKind::Wobble {
539                            dv = dv.suffix(wobble_suffix(spec.name));
540                        }
541                        ui.add(dv)
542                    };
543                    #[cfg(test)]
544                    test_rects::push(format!("param{i}_{j}"), r.rect);
545                    if r.changed() {
546                        a.set_at(lt, v);
547                    }
548                    g.note(&r);
549                    key_buttons(ui, a, lt, palette, &mut g, (i, j));
550                });
551                ui.end_row();
552            }
553        });
554    }
555    if let Some(i) = copy {
556        PARAM_CLIP.with(|c| *c.borrow_mut() = clip.effects.get(i).cloned());
557    }
558    if let Some(i) = paste {
559        let src = PARAM_CLIP.with(|c| c.borrow().clone());
560        if let (Some(src), Some(dst)) = (src, clip.effects.get_mut(i)) {
561            if src.kind == dst.kind {
562                dst.params = src.params.clone();
563                dst.shader = src.shader.clone();
564                dst.mask = src.mask.clone();
565                g.click();
566            }
567        }
568    }
569    if let Some((a, b)) = swap {
570        clip.effects.swap(a, b);
571        g.click();
572    }
573    if let Some(i) = remove {
574        clip.effects.remove(i);
575        if out.mask_for == Some(i) {
576            out.mask_for = None;
577        }
578        g.click();
579    }
580    if clip.effects.is_empty() {
581        ui.label("No effects — click a card above to add one");
582    }
583
584    // ---- save the clip's animation as a motion preset ----
585    if clip.all_animated().iter().any(|a| a.is_animated()) {
586        ui.separator();
587        ui.horizontal(|ui| {
588            let name_id = ui.id().with("motion_name");
589            let mut name: String = ui.ctx().data_mut(|d| d.get_temp(name_id).unwrap_or_default());
590            ui.add(egui::TextEdit::singleline(&mut name).hint_text("Preset name").desired_width(120.0));
591            if ui.add_enabled(!name.trim().is_empty(), Button::new("Save as motion preset")).clicked() {
592                let p = crate::engine::presets::capture_motion(name.trim(), &clip);
593                PENDING_MOTION.with(|s| *s.borrow_mut() = Some(p));
594                name.clear();
595            }
596            ui.ctx().data_mut(|d| d.insert_temp(name_id, name));
597        });
598    }
599
600    if g.start {
601        undo(project);
602    }
603    if g.changed {
604        if let Some(c) = project.clip_mut(id) {
605            *c = clip;
606        }
607    }
608    out.edited |= g.changed;
609    out
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::model::{Clip, ClipKind};
616    use eframe::egui::{vec2, Color32, Event, Modifiers, PointerButton, Pos2, RawInput, Rect};
617
618    struct Harness {
619        ctx: egui::Context,
620        project: Project,
621        selection: Vec<Id>,
622        undos: usize,
623        time: f64,
624        playhead: f64,
625        last: EffectsResponse,
626        shapes: Vec<egui::epaint::ClippedShape>,
627    }
628
629    impl Harness {
630        fn new() -> Self {
631            let mut project = Project::new();
632            project.tracks[0].clips.push(Clip::new(7, ClipKind::Video, "v", 0.0, 4.0));
633            Self {
634                ctx: egui::Context::default(),
635                project,
636                selection: vec![7],
637                undos: 0,
638                time: 0.0,
639                playhead: 1.0,
640                last: EffectsResponse::default(),
641                shapes: Vec::new(),
642            }
643        }
644        /// One update. egui may run the ui closure twice (multi-pass layout, e.g. the first frame a
645        /// collapsing header appears), so the responses of every pass are folded together — a click
646        /// landing in a discarded pass still mutated the project.
647        fn frame(&mut self, events: Vec<Event>) -> bool {
648            self.time += 0.05;
649            let input = RawInput {
650                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(500.0, 900.0))),
651                time: Some(self.time),
652                events,
653                ..Default::default()
654            };
655            let pal = Palette::new(true, Color32::WHITE);
656            let Harness { ctx, project, selection, undos, playhead, last, shapes, .. } = self;
657            let playhead = *playhead;
658            let mut out = EffectsResponse::default();
659            let full = ctx.run(input, |ctx| {
660                egui::CentralPanel::default().show(ctx, |ui| {
661                    let mut undo = |_: &Project| *undos += 1;
662                    let r = show(ui, project, selection, playhead, &pal, &mut undo);
663                    out.edited |= r.edited;
664                    out.open_nodes |= r.open_nodes;
665                    out.mask_for = r.mask_for.or(out.mask_for);
666                });
667            });
668            *shapes = full.shapes;
669            let edited = out.edited;
670            *last = out;
671            edited
672        }
673        /// Centre of the first painted text containing `label` — how a popup's entries are found.
674        fn text_at(&self, label: &str) -> Option<Pos2> {
675            self.shapes.iter().find_map(|c| match &c.shape {
676                egui::epaint::Shape::Text(t) if t.galley.text().contains(label) => {
677                    Some(t.visual_bounding_rect().center())
678                }
679                _ => None,
680            })
681        }
682        fn button(&mut self, pos: Pos2, button: PointerButton, pressed: bool) -> bool {
683            self.frame(vec![Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE }])
684        }
685        fn click(&mut self, pos: Pos2) -> bool {
686            let mut e = self.frame(vec![Event::PointerMoved(pos)]);
687            e |= self.frame(vec![Event::PointerButton {
688                pos,
689                button: PointerButton::Primary,
690                pressed: true,
691                modifiers: Modifiers::NONE,
692            }]);
693            e |= self.frame(vec![Event::PointerButton {
694                pos,
695                button: PointerButton::Primary,
696                pressed: false,
697                modifiers: Modifiers::NONE,
698            }]);
699            e
700        }
701        fn clip(&self) -> &Clip {
702            &self.project.tracks[0].clips[0]
703        }
704        fn rect(&self, name: &str) -> Rect {
705            test_rects::get(name).unwrap_or_else(|| panic!("no widget rect for {name}"))
706        }
707    }
708
709    #[test]
710    fn catalogue_is_grouped_and_searchable() {
711        let cats = categories();
712        assert!(cats.contains(&"Adjustments") && cats.contains(&"Stylize") && cats.contains(&"Custom"));
713        assert_eq!(cats.len(), cats.iter().collect::<std::collections::HashSet<_>>().len(), "no duplicates");
714        assert!(matches(EffectKind::Blur, ""));
715        assert!(matches(EffectKind::Blur, "blu"));
716        assert!(matches(EffectKind::Blur, "STYL"), "category matches too");
717        assert!(!matches(EffectKind::Blur, "chroma"));
718    }
719
720    /// The grid renders without any thumbnail (no GL) and with one, and stays clickable both ways.
721    #[test]
722    fn grid_renders_with_and_without_thumbnails() {
723        clear_thumbnails();
724        let mut h = Harness::new();
725        h.frame(vec![]);
726        let bare = h.rect("card_Blur");
727        assert!(bare.width() >= CARD.0 - 1.0 && bare.height() >= CARD.1, "card is card-sized: {bare:?}");
728        assert_eq!(thumbnail_count(), 0);
729
730        set_thumbnail(EffectKind::Blur, egui::TextureId::Managed(1), [96, 54]);
731        assert_eq!(thumbnail_count(), 1);
732        h.frame(vec![]);
733        let with = h.rect("card_Blur");
734        assert_eq!((with.width(), with.height()), (bare.width(), bare.height()), "same layout either way");
735        clear_thumbnails();
736    }
737
738    #[test]
739    fn card_click_adds_effect_with_one_undo() {
740        clear_thumbnails();
741        let mut h = Harness::new();
742        h.frame(vec![]);
743        let r = h.rect("card_Blur");
744        let edited = h.click(r.center());
745        assert!(edited, "clicking the Blur card adds an effect");
746        assert_eq!(h.clip().effects.len(), 1);
747        assert_eq!(h.clip().effects[0].kind, EffectKind::Blur);
748        assert_eq!(h.undos, 1);
749    }
750
751    /// Right-clicking a card opens its quick actions instead of dragging it away: the secondary button
752    /// never arms a dnd payload, and "all clips on this track" reaches past the selection.
753    #[test]
754    fn card_right_click_is_a_menu_not_a_drag() {
755        clear_thumbnails();
756        let mut h = Harness::new();
757        h.project.tracks[0].clips.push(Clip::new(8, ClipKind::Video, "v2", 4.0, 4.0));
758        h.frame(vec![]);
759        let card = h.rect("card_Blur").center();
760        h.frame(vec![Event::PointerMoved(card)]);
761        h.button(card, PointerButton::Secondary, true);
762        h.frame(vec![]);
763        assert!(!egui::DragAndDrop::has_any_payload(&h.ctx), "the secondary button must never drag a card");
764        h.button(card, PointerButton::Secondary, false);
765        h.frame(vec![]);
766        let item = h.text_at("Add to all clips on this track").expect("quick action menu");
767        assert!(h.click(item), "the menu entry adds the effect");
768        assert_eq!(h.undos, 1);
769        let stacks: Vec<usize> = h.project.tracks[0].clips.iter().map(|c| c.effects.len()).collect();
770        assert_eq!(stacks, vec![1, 1], "every clip on the track got it, not just the selected one");
771    }
772
773    #[test]
774    fn stack_reorder_with_one_undo() {
775        let mut h = Harness::new();
776        {
777            let c = &mut h.project.tracks[0].clips[0];
778            c.effects.push(Effect::new(EffectKind::Blur));
779            c.effects.push(Effect::new(EffectKind::Invert));
780        }
781        h.frame(vec![]);
782        let r = h.rect("down0");
783        assert!(h.click(r.center()));
784        assert_eq!(h.clip().effects[0].kind, EffectKind::Invert);
785        assert_eq!(h.clip().effects[1].kind, EffectKind::Blur);
786        assert_eq!(h.undos, 1);
787    }
788
789    #[test]
790    fn stack_remove_with_one_undo() {
791        let mut h = Harness::new();
792        h.project.tracks[0].clips[0].effects.push(Effect::new(EffectKind::Blur));
793        h.frame(vec![]);
794        let r = h.rect("del0");
795        assert!(h.click(r.center()));
796        assert!(h.clip().effects.is_empty());
797        assert_eq!(h.undos, 1);
798    }
799
800    #[test]
801    fn param_drag_edits_with_one_undo() {
802        let mut h = Harness::new();
803        h.project.tracks[0].clips[0].effects.push(Effect::new(EffectKind::Blur));
804        h.frame(vec![]);
805        let r = h.rect("param0_0");
806        let from = r.center();
807        h.frame(vec![Event::PointerMoved(from)]);
808        h.frame(vec![Event::PointerButton {
809            pos: from,
810            button: PointerButton::Primary,
811            pressed: true,
812            modifiers: Modifiers::NONE,
813        }]);
814        let mut edited = false;
815        for i in 1..=4 {
816            let p = from + vec2(10.0 * i as f32, 0.0);
817            edited |= h.frame(vec![Event::PointerMoved(p)]);
818        }
819        edited |= h.frame(vec![Event::PointerButton {
820            pos: from + vec2(40.0, 0.0),
821            button: PointerButton::Primary,
822            pressed: false,
823            modifiers: Modifiers::NONE,
824        }]);
825        edited |= h.frame(vec![]);
826        assert!(edited, "dragging the Blur radius should edit the project");
827        let radius = h.clip().effects[0].params[0].value;
828        assert!(radius > 8.0, "radius should have grown from the default: {radius}");
829        assert_eq!(h.undos, 1, "one drag gesture = one undo");
830    }
831
832    /// Flip's two parameters are checkboxes, and toggling one writes 0/1.
833    #[test]
834    fn bool_params_are_checkboxes() {
835        assert!(EffectKind::Flip.is_bool_param(0) && EffectKind::Flip.is_bool_param(1));
836        let mut h = Harness::new();
837        h.project.tracks[0].clips[0].effects.push(Effect::new(EffectKind::Flip));
838        h.frame(vec![]);
839        let r = h.rect("param0_0");
840        assert!(r.width() < 30.0, "a checkbox, not a drag value: {r:?}");
841        assert_eq!(h.clip().effects[0].params[0].value, 1.0);
842        assert!(h.click(r.center()));
843        assert_eq!(h.clip().effects[0].params[0].value, 0.0, "toggled off");
844        assert_eq!(h.undos, 1);
845    }
846
847    #[test]
848    fn mask_button_creates_a_mask_and_reports_the_index() {
849        let mut h = Harness::new();
850        h.project.tracks[0].clips[0].effects.push(Effect::new(EffectKind::Blur));
851        h.frame(vec![]);
852        let r = h.rect("mask0");
853        h.click(r.center());
854        assert_eq!(h.last.mask_for, Some(0), "the app is told which effect to mask");
855        assert!(h.clip().effects[0].mask.is_some(), "a mask was created");
856        assert_eq!(h.undos, 1);
857    }
858
859    #[test]
860    fn a_masked_effect_gets_the_mask_grid_and_a_remove_button() {
861        let mut h = Harness::new();
862        let mut fx = Effect::new(EffectKind::Blur);
863        fx.mask = Some(Mask::default());
864        h.project.tracks[0].clips[0].effects.push(fx);
865        h.frame(vec![]);
866        assert!(h.rect("maskgrid0").height() > 0.0, "the mask parameters are on the panel, not viewport-only");
867        let r = h.rect("maskx0");
868        assert!(h.click(r.center()), "✕ drops the mask");
869        assert!(h.clip().effects[0].mask.is_none());
870        assert_eq!(h.undos, 1);
871    }
872
873    /// A mask shapes pixels: an audio clip's stack gets no mask button and no mask grid, even when a
874    /// mask reached it (Paste Attributes, a hand-edited project).
875    #[test]
876    fn audio_clips_get_no_effect_mask_controls() {
877        let mut h = Harness::new();
878        {
879            let c = &mut h.project.tracks[0].clips[0];
880            c.kind = ClipKind::Audio;
881            let mut fx = Effect::new(EffectKind::Blur);
882            fx.mask = Some(Mask::default());
883            c.effects.push(fx);
884        }
885        h.frame(vec![]);
886        assert!(test_rects::get("del0").is_some(), "the row itself still renders");
887        assert!(test_rects::get("mask0").is_none(), "no Add/Edit mask button");
888        assert!(test_rects::get("maskx0").is_none(), "no mask remove button");
889        assert!(test_rects::get("maskgrid0").is_none(), "no mask parameters");
890        assert_eq!(h.last.mask_for, None);
891    }
892
893    #[test]
894    fn a_clip_with_a_node_graph_takes_no_stack_edits() {
895        clear_thumbnails();
896        let mut h = Harness::new();
897        h.project.ensure_graph(7);
898        let _ = h.project.add_node(7, crate::model::NodeKind::Effect(Effect::new(EffectKind::Invert)), 0.0, 0.0);
899        h.frame(vec![]);
900        let r = h.rect("card_Blur");
901        // gpu::run_chain evaluates the graph and never reads clip.effects — the add would be invisible
902        assert!(!h.click(r.center()));
903        assert!(h.clip().effects.is_empty());
904        assert_eq!(h.undos, 0);
905    }
906
907    /// The node editor is opt-in. A clip whose graph is the bare Input→Output pass-through `ensure_graph`
908    /// makes (e.g. the Nodes pane was once pointed at it) still renders its effect list, so the catalogue
909    /// must keep working on it — this is THE regression that made effects undroppable on footage.
910    #[test]
911    fn a_bare_graph_does_not_block_the_catalogue() {
912        clear_thumbnails();
913        let mut h = Harness::new();
914        h.project.ensure_graph(7);
915        h.frame(vec![]);
916        let r = h.rect("card_Blur");
917        assert!(h.click(r.center()), "a pass-through graph is not a graph");
918        assert_eq!(h.clip().effects.len(), 1);
919        assert_eq!(h.undos, 1);
920    }
921
922    /// Two numbers per effect: when it starts inside the clip and how long it lasts (0 = to the end).
923    #[test]
924    fn effect_window_is_editable_with_one_undo() {
925        let mut h = Harness::new();
926        h.project.tracks[0].clips[0].effects.push(Effect::new(EffectKind::Blur));
927        h.frame(vec![]);
928        assert!(h.clip().effects[0].on_at(0.0), "covers the whole clip by default");
929        let from = h.rect("fxstart0").center();
930        h.frame(vec![Event::PointerMoved(from)]);
931        h.frame(vec![Event::PointerButton {
932            pos: from,
933            button: PointerButton::Primary,
934            pressed: true,
935            modifiers: Modifiers::NONE,
936        }]);
937        let mut edited = false;
938        for i in 1..=4 {
939            edited |= h.frame(vec![Event::PointerMoved(from + vec2(10.0 * i as f32, 0.0))]);
940        }
941        edited |= h.frame(vec![Event::PointerButton {
942            pos: from + vec2(40.0, 0.0),
943            button: PointerButton::Primary,
944            pressed: false,
945            modifiers: Modifiers::NONE,
946        }]);
947        edited |= h.frame(vec![]);
948        assert!(edited);
949        let fx = &h.clip().effects[0];
950        assert!(fx.start > 0.0, "the effect now starts later in the clip: {}", fx.start);
951        assert!(!fx.on_at(0.0) && fx.on_at(4.0), "off before its start, still on to the end");
952        assert_eq!(h.undos, 1, "one drag gesture = one undo");
953    }
954
955    #[test]
956    fn node_editor_button_sets_the_flag() {
957        let mut h = Harness::new();
958        h.frame(vec![]);
959        let r = h.rect("nodes");
960        h.click(r.center());
961        assert!(h.last.open_nodes);
962        assert_eq!(h.undos, 0, "opening the node editor is not an edit");
963    }
964
965    #[test]
966    fn copy_paste_one_parameter_set() {
967        PARAM_CLIP.with(|c| *c.borrow_mut() = None);
968        let mut h = Harness::new();
969        {
970            let c = &mut h.project.tracks[0].clips[0];
971            let mut a = Effect::new(EffectKind::Blur);
972            a.params[0].value = 42.0;
973            c.effects.push(a);
974            c.effects.push(Effect::new(EffectKind::Blur));
975        }
976        h.frame(vec![]);
977        let cp = h.rect("copy0");
978        h.click(cp.center());
979        assert_eq!(h.undos, 0, "copying is not an edit");
980        h.frame(vec![]);
981        let pt = h.rect("paste1");
982        h.click(pt.center());
983        assert_eq!(h.clip().effects[1].params[0].value, 42.0, "parameters pasted");
984        assert_eq!(h.undos, 1, "pasting is one undo");
985        PARAM_CLIP.with(|c| *c.borrow_mut() = None);
986    }
987
988    #[test]
989    fn keyframe_button_off_clip_keys_inside_the_clip() {
990        let mut h = Harness::new();
991        h.project.tracks[0].clips[0].start = 10.0; // clip lives at 10..14
992        h.project.tracks[0].clips[0].effects.push(Effect::new(EffectKind::Blur));
993        h.playhead = 0.0; // playhead well before the clip → local time -10
994        h.frame(vec![]);
995        let r = h.rect("kf0_0");
996        assert!(h.click(r.center()));
997        let keys = &h.clip().effects[0].params[0].keys;
998        assert_eq!(keys.len(), 1);
999        assert!(keys[0].t >= 0.0 && keys[0].t <= 4.0, "key must land inside the clip, got t = {}", keys[0].t);
1000    }
1001
1002    #[test]
1003    fn no_selection_is_harmless() {
1004        let mut h = Harness::new();
1005        h.selection.clear();
1006        assert!(!h.frame(vec![]));
1007        assert_eq!(h.undos, 0);
1008    }
1009
1010    /// No `EffectKind` applies to audio yet (see `EffectKind::applies_to_audio`), so selecting an audio
1011    /// clip must hide every pixel-effect card rather than leave them there to silently no-op on click.
1012    #[test]
1013    fn audio_clip_filters_the_catalogue() {
1014        clear_thumbnails();
1015        let mut h = Harness::new();
1016        h.project.tracks[0].clips[0].kind = ClipKind::Audio;
1017        h.frame(vec![]);
1018        assert!(test_rects::get("card_Blur").is_none(), "a pixel effect must not be offered for an audio clip");
1019    }
1020
1021    #[test]
1022    fn pending_motion_handoff() {
1023        assert!(take_pending_motion().is_none());
1024        PENDING_MOTION.with(|s| *s.borrow_mut() = Some(MotionPreset { name: "m".into(), props: Vec::new() }));
1025        assert_eq!(take_pending_motion().map(|m| m.name), Some("m".into()));
1026        assert!(take_pending_motion().is_none());
1027    }
1028}