simple_editor\ui/
retime.rs

1//! Retime window (Ctrl+R): speed / reverse / freeze for the selected clips (linked clips follow).
2//! egui::Window "Speed / Retime": speed as a percent DragValue (1..10000 %, 100 = normal) + preset buttons
3//! (25 % 50 % 100 % 200 % 400 %), "Reverse" checkbox, "Freeze frame at playhead" button
4//! (Project::freeze_at(playhead, selection)) and "Unfreeze" (clear `freeze`), "Apply" → Project::set_speed
5//! (shows a note when the new length collides with a neighbour and nothing changed). Shows the resulting
6//! duration live. Undo once per applied change. Returns true when the project changed.
7
8use crate::model::{Id, Project};
9use crate::ui::duration_text;
10use eframe::egui;
11
12#[derive(Default)]
13pub struct RetimeUi {
14    pub open: bool,
15    /// Percent being edited (synced from the first selected clip when the window opens / selection changes).
16    pub percent: f64,
17    pub reverse: bool,
18    pub last_clip: Option<Id>,
19    pub note: String,
20}
21
22const NO_ROOM: &str = "No room: the new length would collide with a neighbouring clip.";
23
24/// Apply speed/reverse to the selection with a single undo. False (and a note) when nothing changed.
25fn apply_speed(
26    project: &mut Project,
27    selection: &[Id],
28    speed: f64,
29    reverse: bool,
30    note: &mut String,
31    undo: &mut dyn FnMut(&Project),
32) -> bool {
33    let pre = project.clone();
34    if project.set_speed(selection, speed, reverse) {
35        undo(&pre);
36        note.clear();
37        true
38    } else {
39        *note = NO_ROOM.into();
40        false
41    }
42}
43
44/// Freeze only the clips the playhead is actually over: `freeze_at` stores `src_time(playhead)`, which is
45/// an out-of-range (often negative) source time for a clip elsewhere on the timeline.
46fn apply_freeze(project: &mut Project, selection: &[Id], playhead: f64, undo: &mut dyn FnMut(&Project)) -> bool {
47    let sel: Vec<Id> =
48        selection.iter().copied().filter(|&id| project.clip(id).is_some_and(|c| c.contains(playhead))).collect();
49    if sel.is_empty() {
50        return false;
51    }
52    let pre = project.clone();
53    if project.freeze_at(playhead, &sel).is_empty() {
54        false
55    } else {
56        undo(&pre);
57        true
58    }
59}
60
61fn apply_unfreeze(project: &mut Project, selection: &[Id], undo: &mut dyn FnMut(&Project)) -> bool {
62    let ids = project.expand_links(selection);
63    if !ids.iter().any(|&id| project.clip(id).is_some_and(|c| c.freeze.is_some())) {
64        return false;
65    }
66    let pre = project.clone();
67    undo(&pre);
68    for id in ids {
69        if let Some(c) = project.clip_mut(id) {
70            c.freeze = None;
71        }
72    }
73    true
74}
75
76pub fn show(
77    ctx: &egui::Context,
78    state: &mut RetimeUi,
79    project: &mut Project,
80    selection: &[Id],
81    playhead: f64,
82    undo: &mut dyn FnMut(&Project),
83) -> bool {
84    if !state.open {
85        state.last_clip = None;
86        return false;
87    }
88    let first = selection.iter().copied().find(|&id| project.clip(id).is_some());
89    if state.last_clip != first {
90        state.last_clip = first;
91        state.note.clear();
92        if let Some(c) = first.and_then(|id| project.clip(id)) {
93            state.percent = c.speed * 100.0;
94            state.reverse = c.reverse;
95        }
96    }
97    let mut changed = false;
98    let mut open = state.open;
99    egui::Window::new("Speed / Retime").open(&mut open).resizable(false).show(ctx, |ui| {
100        let Some(clip) = first.and_then(|id| project.clip(id)).cloned() else {
101            ui.weak("Select a clip in the timeline.");
102            return;
103        };
104        ui.horizontal(|ui| {
105            ui.label("Speed");
106            ui.add(egui::DragValue::new(&mut state.percent).range(1.0..=10000.0).suffix(" %").speed(1.0));
107        });
108        ui.horizontal(|ui| {
109            for p in [25.0, 50.0, 100.0, 200.0, 400.0] {
110                if ui.small_button(format!("{p:.0} %")).clicked() {
111                    state.percent = p;
112                }
113            }
114        });
115        ui.checkbox(&mut state.reverse, "Reverse");
116        let new_dur =
117            if clip.freeze.is_some() { clip.duration } else { clip.src_len() / (state.percent / 100.0).max(0.0001) };
118        ui.weak(format!("Duration: {} → {}", duration_text(clip.duration), duration_text(new_dur)));
119        if let Some(f) = clip.freeze {
120            ui.weak(format!("Frozen at source {}", duration_text(f)));
121        }
122        ui.horizontal(|ui| {
123            if ui.button("Apply").clicked() {
124                changed |= apply_speed(project, selection, state.percent / 100.0, state.reverse, &mut state.note, undo);
125            }
126            if ui
127                .add_enabled(clip.contains(playhead), egui::Button::new("Freeze frame at playhead"))
128                .on_disabled_hover_text("Move the playhead over the clip to freeze a frame.")
129                .clicked()
130                && apply_freeze(project, selection, playhead, undo)
131            {
132                changed = true;
133            }
134            if clip.freeze.is_some() && ui.button("Unfreeze").clicked() && apply_unfreeze(project, selection, undo) {
135                changed = true;
136            }
137        });
138        if !state.note.is_empty() {
139            ui.colored_label(ui.visuals().warn_fg_color, &state.note);
140        }
141    });
142    state.open = open;
143    changed
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::model::{Asset, ClipKind};
150
151    fn project_10s() -> (Project, Id) {
152        let a = Asset {
153            id: 0,
154            path: r"C:\media\a.mp4".into(),
155            kind: ClipKind::Video,
156            duration: 10.0,
157            width: 1280,
158            height: 720,
159            fps: 30.0,
160            audio_streams: Vec::new(),
161            codec: String::new(),
162            folder: String::new(),
163            tags: Vec::new(),
164            label: 0,
165            description: String::new(),
166        };
167        let p = Project::from_media(a);
168        let id = p.tracks[0].clips[0].id;
169        (p, id)
170    }
171
172    #[test]
173    fn apply_2x_halves_duration_one_undo() {
174        let (mut p, id) = project_10s();
175        let mut undos = 0;
176        let mut note = String::new();
177        let mut undo = |pre: &Project| {
178            undos += 1;
179            assert!((pre.clip(id).unwrap().duration - 10.0).abs() < 1e-9, "undo sees the pre-change project");
180        };
181        assert!(apply_speed(&mut p, &[id], 2.0, false, &mut note, &mut undo));
182        assert_eq!(undos, 1);
183        assert!(note.is_empty());
184        assert!((p.clip(id).unwrap().duration - 5.0).abs() < 1e-9);
185    }
186
187    #[test]
188    fn collision_leaves_note_and_no_undo() {
189        let (mut p, id) = project_10s();
190        // blocker right after the clip on the same track: half speed would double the duration into it
191        p.tracks[0].clips.push(crate::model::Clip::new(999, ClipKind::Text, "block", 10.0, 2.0));
192        let mut undos = 0;
193        let mut note = String::new();
194        assert!(!apply_speed(&mut p, &[id], 0.5, false, &mut note, &mut |_| undos += 1));
195        assert_eq!(undos, 0);
196        assert_eq!(note, NO_ROOM);
197        assert!((p.clip(id).unwrap().duration - 10.0).abs() < 1e-9);
198    }
199
200    #[test]
201    fn freeze_and_unfreeze() {
202        let (mut p, id) = project_10s();
203        let mut undos = 0;
204        assert!(apply_freeze(&mut p, &[id], 4.0, &mut |_| undos += 1));
205        assert_eq!(undos, 1);
206        let frozen: Vec<Id> = p.all_clips().filter(|(_, c)| c.freeze.is_some()).map(|(_, c)| c.id).collect();
207        assert!(!frozen.is_empty());
208        assert!(apply_unfreeze(&mut p, &frozen, &mut |_| undos += 1));
209        assert_eq!(undos, 2);
210        assert!(p.all_clips().all(|(_, c)| c.freeze.is_none()));
211        // nothing frozen → no-op, no undo
212        assert!(!apply_unfreeze(&mut p, &[id], &mut |_| undos += 1));
213        assert_eq!(undos, 2);
214    }
215
216    /// The playhead outside the clip is not a freeze: no undo, no negative source time.
217    #[test]
218    fn freeze_outside_clip_is_a_no_op() {
219        let (mut p, id) = project_10s();
220        p.clip_mut(id).unwrap().start = 10.0; // clip spans 10..20
221        let mut undos = 0;
222        assert!(!apply_freeze(&mut p, &[id], 0.0, &mut |_| undos += 1));
223        assert_eq!(undos, 0);
224        assert!(p.all_clips().all(|(_, c)| c.freeze.is_none()));
225        // inside the clip still works
226        assert!(apply_freeze(&mut p, &[id], 12.0, &mut |_| undos += 1));
227        assert_eq!(undos, 1);
228        assert!(p.all_clips().any(|(_, c)| c.freeze.is_some_and(|f| f >= 0.0)));
229    }
230
231    /// Headless: window lays out (with and without a selection) and reports no change.
232    #[test]
233    fn show_headless() {
234        let (mut p, id) = project_10s();
235        let ctx = egui::Context::default();
236        for sel in [vec![], vec![id]] {
237            let mut state = RetimeUi { open: true, ..Default::default() };
238            for _ in 0..2 {
239                let _ = ctx.run(egui::RawInput::default(), |ctx| {
240                    let mut undo = |_: &Project| {};
241                    assert!(!show(ctx, &mut state, &mut p, &sel, 0.0, &mut undo));
242                });
243            }
244            if !sel.is_empty() {
245                assert_eq!(state.last_clip, Some(id));
246                assert!((state.percent - 100.0).abs() < 1e-9);
247            }
248        }
249    }
250}