1use 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
35pub const CARD: (f32, f32) = (96.0, 54.0);
37
38thread_local! {
39 static PENDING_MOTION: RefCell<Option<MotionPreset>> = const { RefCell::new(None) };
43 static THUMBS: RefCell<HashMap<EffectKind, (egui::TextureId, [u32; 2])>> = RefCell::new(HashMap::new());
45 static PARAM_CLIP: RefCell<Option<Effect>> = const { RefCell::new(None) };
47}
48
49pub fn take_pending_motion() -> Option<MotionPreset> {
51 PENDING_MOTION.with(|p| p.borrow_mut().take())
52}
53
54pub fn set_thumbnail(kind: EffectKind, texture: egui::TextureId, size: [u32; 2]) {
56 THUMBS.with(|t| t.borrow_mut().insert(kind, (texture, size)));
57}
58
59pub fn clear_thumbnails() {
61 THUMBS.with(|t| t.borrow_mut().clear());
62}
63
64pub 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
73fn 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
92fn fits_selection(kind: EffectKind, audio: Option<bool>) -> bool {
95 audio.map_or(true, |a| kind.applies_to_audio() == a)
96}
97
98fn 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 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 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#[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
188fn 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 pub mask_for: Option<usize>,
204 pub open_nodes: bool,
206 pub edit_shader: Option<usize>,
208}
209
210#[derive(Clone, Copy)]
213enum Scope {
214 Selection,
215 Track,
216}
217
218fn 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 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 if let Some((kind, scope)) = catalogue(ui, palette, audio_sel) {
286 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 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 let Some(id) = first_id else {
323 ui.separator();
324 ui.label("Select a clip");
325 return out;
326 };
327 let orig = project.clip(id).cloned();
329 let Some(mut clip) = orig else { return out };
330 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 assert!(!h.click(r.center()));
903 assert!(h.clip().effects.is_empty());
904 assert_eq!(h.undos, 0);
905 }
906
907 #[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 #[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; h.project.tracks[0].clips[0].effects.push(Effect::new(EffectKind::Blur));
993 h.playhead = 0.0; 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 #[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}