1use crate::model::{Ease, Id, Project, Transition, TransitionKind, ABUT_EPS};
18use crate::theme::Palette;
19use crate::ui::effects_ui::CARD;
20use crate::ui::{DragPayload, Gesture};
21use eframe::egui::{self, pos2, vec2, Button, Color32, DragValue, Rect, Response, Stroke, StrokeKind, Vec2};
22use std::cell::RefCell;
23
24#[cfg(test)]
25use crate::ui::effects_ui::test_rects;
26
27thread_local! {
28 static STOCK: RefCell<Option<egui::TextureHandle>> = const { RefCell::new(None) };
31}
32
33pub fn set_stock(tex: egui::TextureHandle) {
35 STOCK.with(|s| *s.borrow_mut() = Some(tex));
36}
37
38pub struct TransitionsState {
39 pub duration: f64,
41 pub kind: usize,
42 pub color: [u8; 4],
43 pub direction: u8,
44}
45
46impl Default for TransitionsState {
47 fn default() -> Self {
48 Self { duration: 1.0, kind: 0, color: [0, 0, 0, 255], direction: 0 }
49 }
50}
51
52impl TransitionsState {
53 pub fn kind(&self) -> TransitionKind {
55 TransitionKind::ALL[self.kind.min(TransitionKind::ALL.len() - 1)]
56 }
57 pub(crate) fn remember(&mut self, kind: TransitionKind, dur: f64) {
59 self.kind = TransitionKind::ALL.iter().position(|&k| k == kind).unwrap_or(0);
60 self.duration = dur;
61 }
62}
63
64pub(crate) fn right_neighbor(project: &Project, id: Id) -> Option<Id> {
66 let ti = project.track_of(id)?;
67 let c = project.clip(id)?;
68 project.tracks[ti].clips.iter().find(|o| o.id != id && (o.start - c.end()).abs() < ABUT_EPS).map(|o| o.id)
69}
70
71fn has_left(project: &Project, id: Id) -> bool {
73 let Some(ti) = project.track_of(id) else { return false };
74 project.clip(id).is_some_and(|c| project.tracks[ti].left_of(c).is_some())
75}
76
77pub(crate) fn add_transitions(
84 project: &mut Project,
85 ids: &[Id],
86 st: &mut TransitionsState,
87 kind: TransitionKind,
88 dur: f64,
89 at_end: bool,
90) -> usize {
91 st.remember(kind, dur);
92 let mut added = 0;
93 for &id in ids {
94 if project.clip(id).is_none() {
95 continue;
96 }
97 let tid = if at_end {
98 match right_neighbor(project, id) {
99 Some(right) => project.add_transition(right, kind, dur),
100 None => project.add_edge_transition(id, kind, dur, true),
101 }
102 } else if has_left(project, id) {
103 project.add_transition(id, kind, dur)
104 } else {
105 project.add_edge_transition(id, kind, dur, false)
106 };
107 if let Some(tid) = tid {
108 if let Some(t) = project.transition_mut(tid) {
109 t.color = st.color;
110 t.direction = st.direction;
111 }
112 added += 1;
113 }
114 }
115 added
116}
117
118#[derive(Clone, Copy, PartialEq)]
120enum Pos {
121 Start,
123 End,
125 Last,
127 Next,
129}
130
131impl Pos {
132 const ALL: [Pos; 4] = [Pos::Start, Pos::End, Pos::Last, Pos::Next];
133 fn name(self) -> &'static str {
134 match self {
135 Pos::Start => "Start",
136 Pos::End => "End",
137 Pos::Last => "Last",
138 Pos::Next => "Next",
139 }
140 }
141 fn hover(self) -> &'static str {
142 match self {
143 Pos::Start => "At the clip start, blending in from nothing",
144 Pos::End => "At the clip end, blending out to nothing",
145 Pos::Last => "On the cut with the previous clip",
146 Pos::Next => "On the cut with the next clip",
147 }
148 }
149 fn of(tr: &Transition, sel: Id) -> Pos {
151 match tr.edge {
152 crate::model::TransitionEdge::In => Pos::Start,
153 crate::model::TransitionEdge::Out => Pos::End,
154 crate::model::TransitionEdge::Cut => {
155 if tr.right == sel {
156 Pos::Last
157 } else {
158 Pos::Next
159 }
160 }
161 }
162 }
163}
164
165fn move_transition(project: &mut Project, tid: Id, sel: Id, pos: Pos) -> bool {
168 let Some(old) = project.tracks.iter().flat_map(|t| &t.transitions).find(|t| t.id == tid).cloned() else {
169 return false;
170 };
171 let target = match pos {
172 Pos::Next => right_neighbor(project, sel),
173 _ => Some(sel),
174 };
175 let Some(target) = target else { return false };
176 if pos == Pos::Last && !has_left(project, sel) {
177 return false;
178 }
179 project.remove_transition(tid);
180 let nid = match pos {
181 Pos::Start => project.add_edge_transition(target, old.kind, old.duration, false),
182 Pos::End => project.add_edge_transition(target, old.kind, old.duration, true),
183 Pos::Last | Pos::Next => project.add_transition(target, old.kind, old.duration),
184 };
185 if let Some(t) = nid.and_then(|nid| project.transition_mut(nid)) {
186 t.color = old.color;
187 t.direction = old.direction;
188 t.ease = old.ease;
189 }
190 nid.is_some()
191}
192
193pub(crate) fn drop_at_end(clip: &crate::model::Clip, t: f64) -> bool {
197 t > clip.start + clip.duration / 2.0
198}
199
200const PREVIEW_P: f32 = 0.5;
202
203fn dir_vec(direction: u8, size: Vec2) -> Vec2 {
205 match direction {
206 0 => vec2(-size.x, 0.0),
207 1 => vec2(size.x, 0.0),
208 2 => vec2(0.0, -size.y),
209 _ => vec2(0.0, size.y),
210 }
211}
212
213fn wipe_rect(tile: Rect, direction: u8, p: f32) -> Rect {
215 let (w, h) = (tile.width() * p, tile.height() * p);
216 match direction {
217 0 => Rect::from_min_max(tile.min, pos2(tile.left() + w, tile.bottom())),
218 1 => Rect::from_min_max(pos2(tile.right() - w, tile.top()), tile.max),
219 2 => Rect::from_min_max(tile.min, pos2(tile.right(), tile.top() + h)),
220 _ => Rect::from_min_max(pos2(tile.left(), tile.bottom() - h), tile.max),
221 }
222}
223
224fn paint_preview(
227 p: &egui::Painter,
228 tile: Rect,
229 tex: egui::TextureId,
230 kind: TransitionKind,
231 st: &TransitionsState,
232 palette: &Palette,
233) {
234 let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
235 let (a, b) = (Color32::WHITE, palette.accent);
236 match kind {
237 TransitionKind::CrossFade => {
238 p.image(tex, tile, uv, a);
239 p.image(tex, tile, uv, b.gamma_multiply(0.5));
240 }
241 TransitionKind::FadeToColor => {
242 p.image(tex, tile, uv, a);
243 let c = st.color;
244 p.rect_filled(tile, 2.0, Color32::from_rgba_unmultiplied(c[0], c[1], c[2], 190));
245 }
246 TransitionKind::Push => {
247 let d = dir_vec(st.direction, tile.size());
248 let p = p.with_clip_rect(tile);
249 p.image(tex, tile.translate(-d * PREVIEW_P), uv, a);
250 p.image(tex, tile.translate(d * (1.0 - PREVIEW_P)), uv, b);
251 }
252 TransitionKind::Wipe => {
253 p.image(tex, tile, uv, a);
254 p.with_clip_rect(wipe_rect(tile, st.direction, PREVIEW_P)).image(tex, tile, uv, b);
255 }
256 }
257}
258
259fn transition_card(
263 ui: &mut egui::Ui,
264 kind: TransitionKind,
265 st: &TransitionsState,
266 palette: &Palette,
267 selected: bool,
268) -> Response {
269 let font = egui::TextStyle::Small.resolve(ui.style());
270 let name_h = ui.text_style_height(&egui::TextStyle::Small);
271 let id = ui.id().with(("tr_card", kind.name()));
272 let r = crate::ui::drag_source(ui, id, DragPayload::Transition(kind), |ui| {
273 let (rect, _) = ui.allocate_exact_size(vec2(CARD.0, CARD.1 + name_h + 2.0), egui::Sense::hover());
274 let tile = Rect::from_min_size(rect.min, vec2(CARD.0, CARD.1));
275 let p = ui.painter();
276 match STOCK.with(|s| s.borrow().as_ref().map(|t| t.id())) {
277 Some(tex) => paint_preview(p, tile, tex, kind, st, palette),
278 None => {
279 p.rect_filled(tile, 2.0, palette.header);
281 let g = p.layout(kind.name().to_string(), font.clone(), palette.text_dim, CARD.0 - 8.0);
282 p.galley(tile.center() - g.size() / 2.0, g, palette.text_dim);
283 }
284 }
285 let label = p.layout_no_wrap(kind.name().to_string(), font, palette.text);
286 let lx = (rect.left() + (CARD.0 - label.size().x) / 2.0).max(rect.left());
287 p.with_clip_rect(Rect::from_min_max(pos2(rect.left(), tile.bottom()), rect.max)).galley(
288 pos2(lx, tile.bottom() + 2.0),
289 label,
290 palette.text,
291 );
292 });
293 let tile = Rect::from_min_size(r.rect.min, vec2(CARD.0, CARD.1));
294 let border = if selected || r.hovered() { palette.accent } else { palette.border };
295 ui.painter().rect_stroke(tile, 2.0, Stroke::new(if selected { 2.0 } else { 1.0 }, border), StrokeKind::Inside);
296 r.on_hover_text(format!("{} — click to pick, drag onto a cut", kind.name()))
297}
298
299fn direction_row(ui: &mut egui::Ui, dir: &mut u8, g: &mut Gesture) {
300 for (i, name) in ["Left", "Right", "Up", "Down"].iter().enumerate() {
301 g.note(&ui.selectable_value(dir, i as u8, *name));
302 }
303}
304
305pub fn show(
306 ui: &mut egui::Ui,
307 state: &mut TransitionsState,
308 project: &mut Project,
309 selection: &[Id],
310 _playhead: f64,
311 palette: &Palette,
312 undo: &mut dyn FnMut(&Project),
313) -> bool {
314 #[cfg(test)]
315 test_rects::clear();
316 let mut changed = false;
317 let mut g = Gesture::default();
318
319 let ids: Vec<Id> = selection.iter().copied().filter(|&id| project.clip(id).is_some()).collect();
321 let any_left = ids.iter().any(|&id| has_left(project, id));
322 let any_right = ids.iter().any(|&id| right_neighbor(project, id).is_some());
323 let mut apply: Option<bool> = None; let mut every_cut = false;
325
326 ui.strong("Transitions");
328 state.kind = state.kind.min(TransitionKind::ALL.len() - 1);
329 let mut pick = None;
330 ui.horizontal_wrapped(|ui| {
331 for (i, k) in TransitionKind::ALL.into_iter().enumerate() {
332 let r = transition_card(ui, k, state, palette, state.kind == i);
333 #[cfg(test)]
334 test_rects::push(format!("card_{}", k.name()), r.rect);
335 if r.clicked() {
336 pick = Some(i);
337 }
338 r.context_menu(|ui| {
340 if ui.add_enabled(!ids.is_empty(), Button::new("Add at start of selected clip(s)")).clicked() {
341 (pick, apply) = (Some(i), Some(false));
342 ui.close();
343 }
344 if ui.add_enabled(!ids.is_empty(), Button::new("Add at end of selected clip(s)")).clicked() {
345 (pick, apply) = (Some(i), Some(true));
346 ui.close();
347 }
348 if ui.add_enabled(!ids.is_empty(), Button::new("Add at every cut on this track")).clicked() {
349 (pick, every_cut) = (Some(i), true);
350 ui.close();
351 }
352 });
353 }
354 });
355 if let Some(i) = pick {
356 state.kind = i;
357 }
358 let kind = state.kind();
359 ui.horizontal(|ui| {
360 ui.label("Duration");
361 ui.add(DragValue::new(&mut state.duration).range(0.1..=5.0).speed(0.02).suffix(" s"));
362 if kind == TransitionKind::FadeToColor {
363 ui.color_edit_button_srgba_unmultiplied(&mut state.color);
364 }
365 });
366 if kind.has_direction() {
367 ui.horizontal(|ui| {
368 let mut dummy = Gesture::default();
369 direction_row(ui, &mut state.direction, &mut dummy);
370 });
371 }
372
373 let Some(&sel) = ids.first() else {
375 ui.label("Select a clip");
376 return false;
377 };
378 ui.horizontal(|ui| {
379 let hint = |any: bool, cut: &str, edge: &str| if any { cut.to_string() } else { edge.to_string() };
380 let r = ui.add(Button::new("Add at start")).on_hover_text(hint(
381 any_left,
382 "Transition into every selected clip",
383 "No cut at the start — the clip blends in from nothing",
384 ));
385 #[cfg(test)]
386 test_rects::push("add_start".into(), r.rect);
387 if r.clicked() {
388 apply = Some(false);
389 }
390 let r = ui.add(Button::new("Add at end")).on_hover_text(hint(
391 any_right,
392 "Transition out of every selected clip",
393 "No cut at the end — the clip blends out to nothing",
394 ));
395 #[cfg(test)]
396 test_rects::push("add_end".into(), r.rect);
397 if r.clicked() {
398 apply = Some(true);
399 }
400 });
401 if let Some(at_end) = apply {
402 undo(project);
403 let dur = state.duration;
404 changed |= add_transitions(project, &ids, state, kind, dur, at_end) > 0;
405 }
406 if every_cut {
407 let ti = project.track_of(sel);
409 let clips: Vec<Id> = ti.map(|ti| project.tracks[ti].clips.iter().map(|c| c.id).collect()).unwrap_or_default();
410 let cuts: Vec<Id> = clips.into_iter().filter(|&id| has_left(project, id)).collect();
411 if !cuts.is_empty() {
412 undo(project);
413 let dur = state.duration;
414 changed |= add_transitions(project, &cuts, state, kind, dur, false) > 0;
415 }
416 }
417
418 ui.separator();
420 let list: Vec<Transition> = project.transitions_of(sel).iter().map(|&(_, t)| t.clone()).collect();
421 if list.is_empty() {
422 ui.label("No transitions on this clip");
423 }
424 let mut writes: Vec<Transition> = Vec::new();
425 let mut removes: Vec<Id> = Vec::new();
426 let mut moves: Vec<(Id, Pos)> = Vec::new();
427 for (_i, mut tr) in list.into_iter().enumerate() {
428 ui.horizontal(|ui| {
429 egui::ComboBox::from_id_salt(("tr_kind", tr.id)).selected_text(tr.kind.name()).show_ui(ui, |ui| {
430 for k in TransitionKind::ALL {
431 g.note(&ui.selectable_value(&mut tr.kind, k, k.name()));
432 }
433 });
434 let mut pos = Pos::of(&tr, sel);
436 let cur = pos;
437 egui::ComboBox::from_id_salt(("tr_pos", tr.id)).selected_text(pos.name()).show_ui(ui, |ui| {
438 for p in Pos::ALL {
439 let ok = match p {
440 Pos::Start | Pos::End => true,
441 Pos::Last => has_left(project, sel),
442 Pos::Next => right_neighbor(project, sel).is_some(),
443 };
444 let r = ui.add_enabled(ok, Button::selectable(pos == p, p.name()));
445 let r =
446 if ok { r.on_hover_text(p.hover()) } else { r.on_disabled_hover_text("No clip on that side") };
447 if r.clicked() {
448 pos = p;
449 g.click();
450 ui.close();
451 }
452 }
453 });
454 if pos != cur {
455 moves.push((tr.id, pos));
456 }
457 let r = ui.add(
460 DragValue::new(&mut tr.duration)
461 .range(0.1..=5.0)
462 .clamp_existing_to_range(false)
463 .speed(0.02)
464 .suffix(" s"),
465 );
466 #[cfg(test)]
467 test_rects::push(format!("tr_dur{_i}"), r.rect);
468 g.note(&r);
469 if tr.kind == TransitionKind::FadeToColor {
470 g.note(&ui.color_edit_button_srgba_unmultiplied(&mut tr.color));
471 }
472 egui::ComboBox::from_id_salt(("tr_ease", tr.id)).selected_text(tr.ease.name()).show_ui(ui, |ui| {
473 for e in Ease::ALL {
474 g.note(&ui.selectable_value(&mut tr.ease, e, e.name()));
475 }
476 });
477 let r = crate::ui::markers_ui::x_button(ui).on_hover_text("Remove this transition");
478 #[cfg(test)]
479 test_rects::push(format!("tr_del{_i}"), r.rect);
480 if r.clicked() {
481 removes.push(tr.id);
482 g.click();
483 }
484 });
485 if tr.kind.has_direction() {
486 ui.horizontal(|ui| {
487 direction_row(ui, &mut tr.direction, &mut g);
488 });
489 }
490 writes.push(tr);
491 }
492 if g.start {
493 undo(project);
494 }
495 if g.changed {
496 for w in writes {
497 if !removes.contains(&w.id) {
498 if let Some(t) = project.transition_mut(w.id) {
499 *t = w;
500 }
501 }
502 }
503 for &(tid, pos) in &moves {
504 if !removes.contains(&tid) {
505 move_transition(project, tid, sel, pos);
506 }
507 }
508 for id in removes {
509 project.remove_transition(id);
510 }
511 changed = true;
512 }
513 changed
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use crate::model::{Asset, AudioStreamInfo, ClipKind};
520 use eframe::egui::{Event, Modifiers, PointerButton, Pos2, RawInput};
521
522 struct Harness {
523 ctx: egui::Context,
524 state: TransitionsState,
525 project: Project,
526 selection: Vec<Id>,
527 undos: usize,
528 time: f64,
529 shapes: Vec<egui::epaint::ClippedShape>,
530 }
531
532 impl Harness {
533 fn new() -> Self {
535 let mut project = Project::new();
536 let aid = project.add_asset(Asset {
537 id: 0,
538 path: "C:/t.mp4".into(),
539 kind: ClipKind::Video,
540 duration: 10.0,
541 width: 320,
542 height: 240,
543 fps: 30.0,
544 audio_streams: vec![AudioStreamInfo { channels: 2, sample_rate: 48000, ..Default::default() }],
545 codec: String::new(),
546 folder: String::new(),
547 tags: Vec::new(),
548 label: 0,
549 description: String::new(),
550 });
551 project.insert_asset_clips(aid, 0.0, Some(0));
552 project.insert_asset_clips(aid, 10.0, Some(0));
553 Self {
554 ctx: egui::Context::default(),
555 state: TransitionsState::default(),
556 project,
557 selection: Vec::new(),
558 undos: 0,
559 time: 0.0,
560 shapes: Vec::new(),
561 }
562 }
563 fn frame(&mut self, events: Vec<Event>) -> bool {
564 self.time += 0.05;
565 let input = RawInput {
566 screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0))),
567 time: Some(self.time),
568 events,
569 ..Default::default()
570 };
571 let pal = Palette::new(true, Color32::WHITE);
572 let Harness { ctx, state, project, selection, undos, shapes, .. } = self;
573 let mut changed = false;
574 let full = ctx.run(input, |ctx| {
575 egui::CentralPanel::default().show(ctx, |ui| {
576 let mut undo = |_: &Project| *undos += 1;
577 changed |= show(ui, state, project, selection, 0.0, &pal, &mut undo);
578 });
579 });
580 *shapes = full.shapes;
581 changed
582 }
583 fn text_at(&self, label: &str) -> Option<Pos2> {
585 self.shapes.iter().find_map(|c| match &c.shape {
586 egui::epaint::Shape::Text(t) if t.galley.text().contains(label) => {
587 Some(t.visual_bounding_rect().center())
588 }
589 _ => None,
590 })
591 }
592 fn button(&mut self, pos: Pos2, button: PointerButton, pressed: bool) -> bool {
593 self.frame(vec![Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE }])
594 }
595 fn click(&mut self, pos: Pos2) -> bool {
596 self.frame(vec![Event::PointerMoved(pos)]);
597 let mut e = self.frame(vec![Event::PointerButton {
598 pos,
599 button: PointerButton::Primary,
600 pressed: true,
601 modifiers: Modifiers::NONE,
602 }]);
603 e |= self.frame(vec![Event::PointerButton {
604 pos,
605 button: PointerButton::Primary,
606 pressed: false,
607 modifiers: Modifiers::NONE,
608 }]);
609 e |= self.frame(vec![]);
610 e
611 }
612 }
613
614 #[test]
615 fn add_at_start_creates_transition_and_audio_mirror() {
616 let mut h = Harness::new();
617 let v2 = h.project.tracks[0].clips[1].id;
619 let a2 = h.project.tracks[1].clips[1].id;
620 h.selection = vec![v2];
621 h.frame(vec![]);
622 let r = test_rects::get("add_start").expect("add button recorded");
623 assert!(h.click(r.center()));
624 assert_eq!(h.undos, 1);
625 let video_tr: Vec<_> = h.project.tracks[0].transitions.iter().collect();
626 assert_eq!(video_tr.len(), 1);
627 assert_eq!(video_tr[0].right, v2);
628 assert_eq!(video_tr[0].kind, TransitionKind::CrossFade);
629 assert!((video_tr[0].duration - 1.0).abs() < 1e-9);
630 let audio_tr: Vec<_> = h.project.tracks[1].transitions.iter().collect();
631 assert_eq!(audio_tr.len(), 1, "linked audio clip should get the mirrored crossfade");
632 assert_eq!(audio_tr[0].right, a2);
633 assert_eq!(audio_tr[0].kind, TransitionKind::CrossFade);
634 }
635
636 #[test]
637 fn add_at_end_uses_right_neighbor_and_remove_deletes() {
638 let mut h = Harness::new();
639 let v1 = h.project.tracks[0].clips[0].id;
640 let v2 = h.project.tracks[0].clips[1].id;
641 h.selection = vec![v1];
642 h.frame(vec![]);
643 let r = test_rects::get("add_end").expect("add button recorded");
644 assert!(h.click(r.center()));
645 assert_eq!(h.project.tracks[0].transitions.len(), 1);
646 assert_eq!(h.project.tracks[0].transitions[0].right, v2, "end of v1 = cut whose right side is v2");
647 assert_eq!(h.undos, 1);
648 h.frame(vec![]);
650 let r = test_rects::get("tr_del0").expect("remove button recorded");
651 assert!(h.click(r.center()));
652 assert!(h.project.tracks[0].transitions.is_empty());
653 assert_eq!(h.undos, 2);
654 }
655
656 #[test]
657 fn no_neighbor_adds_an_edge_transition() {
658 let mut h = Harness::new();
659 let v1 = h.project.tracks[0].clips[0].id;
661 h.selection = vec![v1];
662 h.frame(vec![]);
663 let r = test_rects::get("add_start").expect("add button recorded");
664 assert!(h.click(r.center()));
665 assert_eq!(h.project.tracks[0].transitions.len(), 1);
666 let tr = &h.project.tracks[0].transitions[0];
667 assert_eq!((tr.right, tr.edge), (v1, crate::model::TransitionEdge::In));
668 assert_eq!(h.undos, 1);
669 }
670
671 #[test]
673 fn add_at_end_without_neighbor_fades_out() {
674 let mut h = Harness::new();
675 let v2 = h.project.tracks[0].clips[1].id;
676 h.selection = vec![v2];
677 assert_eq!(add_transitions(&mut h.project, &[v2], &mut h.state, TransitionKind::CrossFade, 1.0, true), 1);
678 let tr = &h.project.tracks[0].transitions[0];
679 assert_eq!((tr.right, tr.edge), (v2, crate::model::TransitionEdge::Out));
680 }
681
682 #[test]
684 fn move_transition_reanchors_with_settings_kept() {
685 let mut h = Harness::new();
686 let v1 = h.project.tracks[0].clips[0].id;
687 let v2 = h.project.tracks[0].clips[1].id;
688 h.state.color = [9, 8, 7, 255];
689 h.state.direction = 3;
690 assert_eq!(add_transitions(&mut h.project, &[v2], &mut h.state, TransitionKind::Wipe, 0.7, false), 1);
691 let tid = h.project.tracks[0].transitions.iter().find(|t| t.right == v2).unwrap().id;
692 assert!(move_transition(&mut h.project, tid, v1, Pos::Start));
694 let tr = h.project.tracks[0].transitions.iter().find(|t| t.right == v1).expect("moved onto v1");
695 assert_eq!(tr.edge, crate::model::TransitionEdge::In);
696 assert_eq!((tr.kind, tr.direction, tr.color), (TransitionKind::Wipe, 3, [9, 8, 7, 255]));
697 assert!((tr.duration - 0.7).abs() < 1e-9);
698 let tid = tr.id;
699 assert!(move_transition(&mut h.project, tid, v1, Pos::Next));
701 let tr = h.project.tracks[0].transitions.iter().find(|t| t.right == v2).expect("cut transition");
702 assert_eq!(tr.edge, crate::model::TransitionEdge::Cut);
703 let tid = tr.id;
705 assert!(!move_transition(&mut h.project, tid, v2, Pos::Next), "v2 has no right neighbour");
706 assert!(h.project.tracks[0].transitions.iter().any(|t| t.id == tid));
707 }
708
709 #[test]
711 fn add_covers_every_selected_clip_with_one_undo() {
712 let mut h = Harness::new();
713 let aid = h.project.assets[0].id;
714 h.project.insert_asset_clips(aid, 20.0, Some(0)); let v2 = h.project.tracks[0].clips[1].id;
716 let v3 = h.project.tracks[0].clips[2].id;
717 h.selection = vec![v2, v3];
718 h.frame(vec![]);
719 let r = test_rects::get("add_start").expect("add button recorded");
720 assert!(h.click(r.center()));
721 let rights: Vec<Id> = h.project.tracks[0].transitions.iter().map(|t| t.right).collect();
722 assert_eq!(rights.len(), 2, "both cuts of the selection: {rights:?}");
723 assert!(rights.contains(&v2) && rights.contains(&v3));
724 assert_eq!(h.undos, 1, "one undo for the whole selection");
725 }
726
727 #[test]
729 fn apply_records_the_last_used_transition() {
730 let mut h = Harness::new();
731 let v2 = h.project.tracks[0].clips[1].id;
732 h.state.color = [1, 2, 3, 255];
733 h.state.direction = 2;
734 assert_eq!(add_transitions(&mut h.project, &[v2], &mut h.state, TransitionKind::Wipe, 0.4, false), 1);
735 assert_eq!(h.state.kind(), TransitionKind::Wipe, "Ctrl+T would repeat what was just applied");
736 assert!((h.state.duration - 0.4).abs() < 1e-9);
737 let tr = h.project.tracks[0].transitions.iter().find(|t| t.right == v2).expect("transition");
738 assert_eq!((tr.kind, tr.direction, tr.color), (TransitionKind::Wipe, 2, [1, 2, 3, 255]));
739 }
740
741 #[test]
742 fn out_of_range_duration_is_not_rewritten_by_merely_showing() {
743 let mut h = Harness::new();
744 let v1 = h.project.tracks[0].clips[0].id;
745 let v2 = h.project.tracks[0].clips[1].id;
746 let tid = h.project.add_transition(v2, TransitionKind::CrossFade, 1.0).unwrap();
747 h.project.transition_mut(tid).unwrap().duration = 8.0;
748 h.selection = vec![v1];
749 assert!(!h.frame(vec![]), "drawing the panel must not report an edit");
750 assert_eq!(h.undos, 0, "no undo snapshot without a user gesture");
751 assert!((h.project.transitions_of(v1)[0].1.duration - 8.0).abs() < 1e-9);
752 }
753
754 #[test]
756 fn a_dropped_card_picks_the_cut_it_landed_next_to() {
757 let mut h = Harness::new();
758 let v2 = h.project.tracks[0].clips[1].id; let clip = h.project.clip(v2).unwrap().clone();
760 assert!(!drop_at_end(&clip, 11.0), "left half = the cut at its start");
761 assert!(drop_at_end(&clip, 19.0), "right half = the cut at its end");
762 let st = &mut h.state;
763 assert_eq!(add_transitions(&mut h.project, &[v2], st, TransitionKind::Push, 0.5, false), 1);
764 assert_eq!(h.project.tracks[0].transitions[0].right, v2);
765 }
766
767 #[test]
768 fn right_neighbor_finds_abutting_clip() {
769 let h = Harness::new();
770 let v1 = h.project.tracks[0].clips[0].id;
771 let v2 = h.project.tracks[0].clips[1].id;
772 assert_eq!(right_neighbor(&h.project, v1), Some(v2));
773 assert_eq!(right_neighbor(&h.project, v2), None);
774 }
775
776 #[test]
778 fn cards_pick_the_kind_without_a_stock_picture() {
779 let mut h = Harness::new();
780 h.selection = vec![h.project.tracks[0].clips[1].id];
781 h.frame(vec![]);
782 let r = test_rects::get("card_Wipe").expect("card recorded");
783 assert!(r.width() >= CARD.0 - 1.0 && r.height() >= CARD.1, "card is card-sized: {r:?}");
784 h.click(r.center());
785 assert_eq!(h.state.kind(), TransitionKind::Wipe);
786 assert_eq!(h.undos, 0, "picking a kind is not an edit");
787 }
788
789 #[test]
792 fn a_card_only_drags_once_the_pointer_moves() {
793 let mut h = Harness::new();
794 h.selection = vec![h.project.tracks[0].clips[1].id];
795 h.frame(vec![]);
796 let card = test_rects::get("card_Wipe").expect("card recorded").center();
797 h.frame(vec![Event::PointerMoved(card)]);
798 h.button(card, PointerButton::Primary, true);
799 for _ in 0..20 {
800 h.frame(vec![]);
801 }
802 assert!(!egui::DragAndDrop::has_any_payload(&h.ctx), "holding still is not a drag");
803 h.frame(vec![Event::PointerMoved(card + vec2(24.0, 0.0))]);
804 let p = egui::DragAndDrop::payload::<DragPayload>(&h.ctx).expect("moving must arm the payload");
805 assert!(matches!(*p, DragPayload::Transition(TransitionKind::Wipe)), "the card under the press");
806 }
807
808 #[test]
811 fn card_right_click_menu_fills_every_cut() {
812 let mut h = Harness::new();
813 let aid = h.project.assets[0].id;
814 h.project.insert_asset_clips(aid, 20.0, Some(0)); h.selection = vec![h.project.tracks[0].clips[0].id];
816 h.frame(vec![]);
817 let card = test_rects::get("card_Push").expect("card recorded").center();
818 h.frame(vec![Event::PointerMoved(card)]);
819 h.button(card, PointerButton::Secondary, true);
820 h.frame(vec![]);
821 assert!(!egui::DragAndDrop::has_any_payload(&h.ctx), "the secondary button must never drag a card");
822 h.button(card, PointerButton::Secondary, false);
823 h.frame(vec![]);
824 let item = h.text_at("Add at every cut on this track").expect("quick action menu");
825 assert!(h.click(item));
826 assert_eq!(h.project.tracks[0].transitions.len(), 2, "both cuts of the track");
827 assert!(h.project.tracks[0].transitions.iter().all(|t| t.kind == TransitionKind::Push));
828 assert_eq!(h.state.kind(), TransitionKind::Push, "the menu picks the kind it applied");
829 assert_eq!(h.undos, 1);
830 }
831
832 #[test]
834 fn wipe_region_follows_the_direction() {
835 let tile = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0));
836 assert_eq!(wipe_rect(tile, 0, 0.5), Rect::from_min_max(pos2(0.0, 0.0), pos2(50.0, 50.0)));
837 assert_eq!(wipe_rect(tile, 1, 0.5), Rect::from_min_max(pos2(50.0, 0.0), pos2(100.0, 50.0)));
838 assert_eq!(wipe_rect(tile, 2, 0.5), Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 25.0)));
839 assert_eq!(wipe_rect(tile, 3, 0.5), Rect::from_min_max(pos2(0.0, 25.0), pos2(100.0, 50.0)));
840 assert_eq!(dir_vec(0, vec2(100.0, 50.0)), vec2(-100.0, 0.0));
842 assert_eq!(dir_vec(3, vec2(100.0, 50.0)), vec2(0.0, 50.0));
843 }
844}