simple_editor\ui/
transitions_ui.rs

1//! Transitions panel. Catalogue: one CARD per `TransitionKind` (same size and frame as an effect card),
2//! previewing the transition half-way over the stock picture the effect thumbnails are rendered from —
3//! the app hands it over with `set_stock`, and without it a card falls back to a neutral named tile.
4//! A card selects the kind, right-clicking it applies the transition straight away (start / end of the
5//! selection, or every cut on its track), and a press-and-move drags `DragPayload::Transition`. Next to
6//! the grid the default duration DragValue (0.1..5 s), a colour button for FadeToColor and a direction
7//! selector for Push/Wipe. "Add at start" / "Add at end" apply it to EVERY selected clip through `add_transitions`,
8//! which is also what the menu, the hotkeys and MCP call — it records the choice in `TransitionsState`,
9//! so Ctrl+T (Action::AddLastTransition) repeats whatever was applied last, whichever path applied it.
10//! A clip with no neighbour on that side gets an EDGE transition (blend from/to nothing) instead of
11//! a cut transition, so lone clips can fade in/out too.
12//! Below: the transitions touching the first selected clip (Project::transitions_of): kind combo,
13//! position combo (Start / End / Last / Next — re-anchors the transition relative to the clip),
14//! duration, colour/direction/ease editors, remove. Returns true when the project changed (call
15//! `undo` once per gesture first).
16
17use 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    /// The catalogue's stock picture, uploaded by the app from the same source as the effect thumbnails.
29    /// The handle lives here so the texture outlives the app's own thumbnail list.
30    static STOCK: RefCell<Option<egui::TextureHandle>> = const { RefCell::new(None) };
31}
32
33/// The app hands over the picture the effect catalogue renders from; the cards preview over it.
34pub fn set_stock(tex: egui::TextureHandle) {
35    STOCK.with(|s| *s.borrow_mut() = Some(tex));
36}
37
38pub struct TransitionsState {
39    /// Catalogue settings for the next transition to add — and the memory of the last one added.
40    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    /// The selected kind — also the one Ctrl+T repeats, because every apply path records here.
54    pub fn kind(&self) -> TransitionKind {
55        TransitionKind::ALL[self.kind.min(TransitionKind::ALL.len() - 1)]
56    }
57    /// Remember what was just applied (from any path) so Ctrl+T is never stale.
58    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
64/// The clip starting exactly where `id` ends, on the same track.
65pub(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
71/// A clip abuts the left edge of `id`, i.e. there is a cut to put a transition on.
72fn 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
77/// Add a transition at the cut left of every id (or at its right edge with `at_end`), with the colour
78/// and direction from `st`, and record the choice in `st`. A clip with no neighbour on that side gets
79/// an edge transition instead (blend from/to nothing). THE funnel: panel, menu, hotkeys and MCP all
80/// come through here (or call `remember`), which is what keeps Ctrl+T on the last transition actually
81/// used. Returns how many were added — a transition always belongs to the clip on the RIGHT of the cut,
82/// and `Project::add_transition` replaces the one already on that cut, so overlapping selections are fine.
83pub(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/// Where a transition sits relative to the selected clip — the panel's position selector.
119#[derive(Clone, Copy, PartialEq)]
120enum Pos {
121    /// Edge In: blend from nothing at the clip start.
122    Start,
123    /// Edge Out: blend to nothing at the clip end.
124    End,
125    /// Cut with the last (previous) clip.
126    Last,
127    /// Cut with the next clip.
128    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    /// The position `tr` occupies relative to clip `sel`.
150    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
165/// Re-anchor a transition at a new position relative to `sel`, keeping its settings.
166/// Returns true when it moved (the target position must exist).
167fn 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
193/// A transition card dropped on a clip at timeline time `t`: which of the clip's two cuts it means.
194/// The half you drop on picks it, so the gesture reads the same as "Add at start" / "Add at end".
195/// Shared so the timeline's drop highlight and the app's drop handler cannot drift apart.
196pub(crate) fn drop_at_end(clip: &crate::model::Clip, t: f64) -> bool {
197    t > clip.start + clip.duration / 2.0
198}
199
200/// Half-way through the transition: enough to show the wipe/push/fade on a still card.
201const PREVIEW_P: f32 = 0.5;
202
203/// Where the incoming clip travels from, mirroring `compose::dir_vec` (0 left, 1 right, 2 up, 3 down).
204fn 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
213/// The part of the tile the incoming clip has reached at `p` (same regions as `compose`'s wipe).
214fn 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
224/// Draw the transition over the stock picture: A is the picture, B the same picture tinted, so the two
225/// sides of the cut read apart without decoding a second image.
226fn 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
259/// One catalogue card: the preview tile with the name underneath, selected/hover border. Clicking it
260/// selects the kind, right-clicking opens its quick actions; a deliberate press-and-move drags
261/// `DragPayload::Transition` (`ui::drag_source`).
262fn 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                // no GPU thumbnails yet: a neutral tile that still names the transition
280                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    // the cuts the selection offers — needed by the cards' quick-action menus, which are drawn first
320    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; // Some(at_end)
324    let mut every_cut = false;
325
326    // ---- catalogue / defaults for the next transition ----
327    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            // a quick action also picks the kind, so Ctrl+T repeats what the menu just applied
339            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    // ---- add at the cuts around every selected clip ----
374    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        // every clip on the track that has something abutting its left edge is a cut
408        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    // ---- transitions touching the selected clip ----
419    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            // where the transition sits relative to the selected clip; picking a spot re-anchors it
435            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            // clamp_existing_to_range(false): clamping an out-of-range duration counts as a change and
458            // would fake an edit (undo snapshot + dirty flag) just by drawing the panel.
459            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        /// Two linked video+audio clip pairs abutting at t = 10.
534        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        /// Centre of the first painted text containing `label` — how a popup's entries are found.
584        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        // second video clip (starts at 10) — its cut with the first is at its start
618        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        // the transition touches v1, so it is listed; remove it
649        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        // select the FIRST clip: nothing ends at its start (t = 0), so it blends in from nothing
660        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    /// The last clip of the track gets an Out edge from "Add at end" (nothing abuts it).
672    #[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    /// Re-anchoring keeps the transition's settings and moves it to the picked position.
683    #[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        // Cut at v2's start ("Last") → edge In at v1's start ("Start" relative to v1)
693        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        // ... and "Next" from v1 lands back on the cut whose right side is v2
700        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        // moving to a cut that does not exist is refused and loses nothing
704        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    /// Every selected clip gets a transition, not just the first one, and it is one undo entry.
710    #[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)); // three abutting pairs: cuts at 10 and 20
715        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    /// Applying through the shared funnel records the choice, colour and direction included.
728    #[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    /// Dropping a card on a clip picks the near cut, and applying it there is exactly the button's job.
755    #[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; // 10..20
759        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    /// The card grid is drawn (and clickable) with no stock picture, and a click picks the kind.
777    #[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    /// A card is clickable first and a drag source second: a stationary press — even one held long
790    /// past egui's click timeout — arms nothing, and only real pointer travel hands the payload over.
791    #[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    /// Right-clicking a card applies it instead of dragging it: the menu's "every cut" entry fills the
809    /// whole track in one undo.
810    #[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)); // cuts at 10 and 20
815        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    /// The preview geometry follows the direction the same way the compositor does.
833    #[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        // the incoming clip travels from the named edge (compose::dir_vec)
841        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}