use crate::edit::{Command, EditError};
use crate::timeline::Timeline;
#[derive(Debug)]
pub struct Editor {
history: Vec<Timeline>,
cursor: usize,
next_clip_id: u64,
next_track_id: u64,
group: Option<Group>,
}
#[derive(Debug)]
struct Group {
working: Timeline,
dirty: bool,
}
impl Editor {
#[must_use]
pub fn new(initial: Timeline) -> Self {
let next_clip_id = initial.next_clip_id;
let next_track_id = initial.next_track_id;
Self {
history: vec![initial],
cursor: 0,
next_clip_id,
next_track_id,
group: None,
}
}
#[must_use]
pub fn current(&self) -> &Timeline {
match &self.group {
Some(g) => &g.working,
None => &self.history[self.cursor],
}
}
fn edit_current(&mut self, command: &Command) -> Result<Timeline, EditError> {
let mut seed = self.current().clone();
seed.next_clip_id = self.next_clip_id;
seed.next_track_id = self.next_track_id;
let next = crate::edit::apply(&seed, command)?;
self.next_clip_id = next.next_clip_id;
self.next_track_id = next.next_track_id;
Ok(next)
}
pub fn apply(&mut self, command: &Command) -> Result<&Timeline, EditError> {
let next = self.edit_current(command)?;
if let Some(g) = &mut self.group {
g.working = next;
g.dirty = true;
} else {
self.history.truncate(self.cursor + 1);
self.history.push(next);
self.cursor += 1;
}
Ok(self.current())
}
pub fn amend(&mut self, command: &Command) -> Result<&Timeline, EditError> {
let next = self.edit_current(command)?;
self.replace_current(next);
Ok(self.current())
}
pub fn replace_current(&mut self, timeline: Timeline) {
self.next_clip_id = self.next_clip_id.max(timeline.next_clip_id);
self.next_track_id = self.next_track_id.max(timeline.next_track_id);
if let Some(g) = &mut self.group {
g.working = timeline;
g.dirty = true;
} else {
self.history.truncate(self.cursor + 1);
self.history[self.cursor] = timeline;
}
}
pub fn begin_group(&mut self) {
if self.group.is_none() {
self.group = Some(Group {
working: self.current().clone(),
dirty: false,
});
}
}
pub fn commit_group(&mut self) {
if let Some(g) = self.group.take()
&& g.dirty
{
self.history.truncate(self.cursor + 1);
self.history.push(g.working);
self.cursor += 1;
}
}
pub fn cancel_group(&mut self) {
self.group = None;
}
pub fn undo(&mut self) -> Option<&Timeline> {
if self.group.is_some() || self.cursor == 0 {
return None;
}
self.cursor -= 1;
Some(&self.history[self.cursor])
}
pub fn redo(&mut self) -> Option<&Timeline> {
if self.group.is_some() || self.cursor + 1 >= self.history.len() {
return None;
}
self.cursor += 1;
Some(&self.history[self.cursor])
}
#[must_use]
pub fn can_undo(&self) -> bool {
self.group.is_none() && self.cursor > 0
}
#[must_use]
pub fn can_redo(&self) -> bool {
self.group.is_none() && self.cursor + 1 < self.history.len()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::Clip;
fn timeline(fps: f64) -> Timeline {
Timeline::builder()
.canvas(1920, 1080)
.frame_rate(fps)
.video_track(vec![Clip::new("a.mp4")])
.build()
.unwrap()
}
fn set_fps(fps: f64) -> Command {
Command::SetFrameRate { fps }
}
#[test]
fn editor_new_should_start_with_no_undo_or_redo() {
let ed = Editor::new(timeline(30.0));
assert!(!ed.can_undo());
assert!(!ed.can_redo());
assert!((ed.current().frame_rate() - 30.0).abs() < f64::EPSILON);
}
#[test]
fn editor_apply_should_advance_and_enable_undo() {
let mut ed = Editor::new(timeline(30.0));
let cur = ed.apply(&set_fps(24.0)).unwrap();
assert!((cur.frame_rate() - 24.0).abs() < f64::EPSILON);
assert!(ed.can_undo());
assert!(!ed.can_redo());
}
#[test]
fn editor_undo_should_restore_previous_version() {
let mut ed = Editor::new(timeline(30.0));
ed.apply(&set_fps(24.0)).unwrap();
let prev = ed.undo().unwrap();
assert!((prev.frame_rate() - 30.0).abs() < f64::EPSILON);
assert!(!ed.can_undo());
assert!(ed.can_redo());
}
#[test]
fn editor_undo_redo_should_round_trip_an_added_marker() {
use crate::Marker;
use std::time::Duration;
let mut ed = Editor::new(timeline(30.0));
let after = ed
.apply(&Command::AddMarker {
marker: Marker::new(Duration::from_secs(1)),
})
.unwrap();
assert_eq!(after.markers().len(), 1);
let undone = ed.undo().unwrap();
assert!(undone.markers().is_empty(), "undo removes the added marker");
let redone = ed.redo().unwrap();
assert_eq!(redone.markers().len(), 1, "redo restores the marker");
}
#[test]
fn editor_undo_should_restore_all_grouped_members_in_one_step() {
use std::time::Duration;
let base = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![
Clip::new("a.mp4"),
Clip::new("b.mp4").offset(Duration::from_secs(10)),
])
.build()
.unwrap();
let a = base.video_tracks()[0].clips[0].id;
let b = base.video_tracks()[0].clips[1].id;
let mut ed = Editor::new(base);
ed.apply(&Command::GroupClips { clips: vec![a, b] })
.unwrap();
let moved = ed
.apply(&Command::MoveClip {
clip: a,
offset: Duration::from_secs(5),
})
.unwrap();
assert_eq!(
moved.video_tracks()[0].clips[0].offset,
Duration::from_secs(5)
);
assert_eq!(
moved.video_tracks()[0].clips[1].offset,
Duration::from_secs(15)
);
let undone = ed.undo().unwrap();
assert_eq!(undone.video_tracks()[0].clips[0].offset, Duration::ZERO);
assert_eq!(
undone.video_tracks()[0].clips[1].offset,
Duration::from_secs(10)
);
let redone = ed.redo().unwrap();
assert_eq!(
redone.video_tracks()[0].clips[1].offset,
Duration::from_secs(15)
);
}
#[test]
fn editor_redo_should_reapply_the_undone_version() {
let mut ed = Editor::new(timeline(30.0));
ed.apply(&set_fps(24.0)).unwrap();
ed.undo().unwrap();
let next = ed.redo().unwrap();
assert!((next.frame_rate() - 24.0).abs() < f64::EPSILON);
assert!(!ed.can_redo());
}
#[test]
fn editor_undo_at_start_should_return_none() {
let mut ed = Editor::new(timeline(30.0));
assert!(ed.undo().is_none());
assert!((ed.current().frame_rate() - 30.0).abs() < f64::EPSILON);
}
#[test]
fn editor_redo_at_end_should_return_none() {
let mut ed = Editor::new(timeline(30.0));
ed.apply(&set_fps(24.0)).unwrap();
assert!(ed.redo().is_none());
}
#[test]
fn editor_new_edit_after_undo_should_truncate_redo() {
let mut ed = Editor::new(timeline(30.0));
ed.apply(&set_fps(24.0)).unwrap();
ed.apply(&set_fps(48.0)).unwrap();
ed.undo().unwrap(); let cur = ed.apply(&set_fps(60.0)).unwrap(); assert!((cur.frame_rate() - 60.0).abs() < f64::EPSILON);
assert!(!ed.can_redo());
assert!(ed.redo().is_none());
}
#[test]
fn editor_apply_error_should_leave_history_unchanged() {
let mut ed = Editor::new(timeline(30.0));
ed.apply(&set_fps(24.0)).unwrap();
let err = ed.apply(&set_fps(0.0)).unwrap_err(); assert_eq!(err, EditError::InvalidFrameRate(0.0));
assert!(ed.can_undo());
assert!(!ed.can_redo());
assert!((ed.current().frame_rate() - 24.0).abs() < f64::EPSILON);
}
#[test]
fn editor_should_preserve_clip_id_across_undo_redo() {
let mut ed = Editor::new(timeline(30.0));
let track = ed.current().video_tracks()[0].id;
ed.apply(&Command::AddClip {
track,
clip: Box::new(Clip::new("x.mp4")),
})
.unwrap();
let id = ed.current().video_tracks()[0].clips[1].id;
ed.undo().unwrap();
let after = ed.redo().unwrap();
assert_eq!(
after.video_tracks()[0].clips[1].id,
id,
"undo/redo must not renumber the clip"
);
}
#[test]
fn editor_should_not_reuse_ids_across_undo() {
let mut ed = Editor::new(timeline(30.0));
let track = ed.current().video_tracks()[0].id;
ed.apply(&Command::AddClip {
track,
clip: Box::new(Clip::new("a.mp4")),
})
.unwrap();
let first = ed.current().video_tracks()[0].clips[1].id;
ed.undo().unwrap();
let after = ed
.apply(&Command::AddClip {
track,
clip: Box::new(Clip::new("b.mp4")),
})
.unwrap();
let second = after.video_tracks()[0].clips[1].id;
assert_ne!(
first, second,
"an id used by a discarded branch must not be reused"
);
}
#[test]
fn editor_apply_batch_should_be_one_undo_step() {
let mut ed = Editor::new(timeline(30.0));
let track = ed.current().video_tracks()[0].id;
ed.apply(&Command::Batch(vec![
Command::AddClip {
track,
clip: Box::new(Clip::new("a.mp4")),
},
Command::AddClip {
track,
clip: Box::new(Clip::new("b.mp4")),
},
]))
.unwrap();
assert_eq!(ed.current().video_tracks()[0].clips.len(), 3);
let prev = ed.undo().unwrap();
assert_eq!(prev.video_tracks()[0].clips.len(), 1);
}
#[test]
fn editor_group_should_coalesce_to_one_undo_step() {
let mut ed = Editor::new(timeline(30.0));
ed.begin_group();
ed.apply(&set_fps(24.0)).unwrap();
ed.apply(&set_fps(48.0)).unwrap();
assert!(
(ed.current().frame_rate() - 48.0).abs() < f64::EPSILON,
"the working version reflects grouped edits live"
);
ed.commit_group();
assert!((ed.current().frame_rate() - 48.0).abs() < f64::EPSILON);
let prev = ed.undo().unwrap();
assert!((prev.frame_rate() - 30.0).abs() < f64::EPSILON);
assert!(!ed.can_undo());
}
#[test]
fn editor_empty_group_should_add_no_step() {
let mut ed = Editor::new(timeline(30.0));
ed.apply(&set_fps(24.0)).unwrap();
ed.begin_group();
ed.commit_group(); assert!(!ed.can_redo());
let prev = ed.undo().unwrap();
assert!((prev.frame_rate() - 30.0).abs() < f64::EPSILON);
assert!(!ed.can_undo());
}
#[test]
fn editor_cancel_group_should_discard_edits() {
let mut ed = Editor::new(timeline(30.0));
ed.begin_group();
ed.apply(&set_fps(24.0)).unwrap();
ed.cancel_group();
assert!(
(ed.current().frame_rate() - 30.0).abs() < f64::EPSILON,
"cancel reverts to the pre-group version"
);
assert!(!ed.can_undo(), "cancel added no step");
}
#[test]
fn editor_undo_redo_should_be_disabled_during_a_group() {
let mut ed = Editor::new(timeline(30.0));
ed.apply(&set_fps(24.0)).unwrap();
ed.begin_group();
ed.apply(&set_fps(48.0)).unwrap();
assert!(!ed.can_undo());
assert!(ed.undo().is_none());
assert!(ed.redo().is_none());
ed.commit_group();
assert!(ed.can_undo());
let prev = ed.undo().unwrap();
assert!((prev.frame_rate() - 24.0).abs() < f64::EPSILON);
}
#[test]
fn editor_amend_should_update_current_without_growing_history() {
let mut ed = Editor::new(timeline(30.0));
ed.apply(&set_fps(24.0)).unwrap(); ed.amend(&set_fps(48.0)).unwrap(); assert!((ed.current().frame_rate() - 48.0).abs() < f64::EPSILON);
assert!(ed.can_undo());
assert!(!ed.can_redo());
let prev = ed.undo().unwrap();
assert!((prev.frame_rate() - 30.0).abs() < f64::EPSILON);
assert!(!ed.can_undo());
}
#[test]
fn editor_replace_current_should_seat_value_and_drop_redo() {
let mut ed = Editor::new(timeline(30.0));
ed.apply(&set_fps(24.0)).unwrap();
ed.apply(&set_fps(48.0)).unwrap();
ed.undo().unwrap(); assert!(ed.can_redo());
ed.replace_current(timeline(60.0));
assert!((ed.current().frame_rate() - 60.0).abs() < f64::EPSILON);
assert!(!ed.can_redo(), "seating a value drops the redo tail");
}
#[test]
fn editor_group_should_keep_ids_monotonic() {
let mut ed = Editor::new(timeline(30.0));
let track = ed.current().video_tracks()[0].id;
ed.begin_group();
ed.apply(&Command::AddClip {
track,
clip: Box::new(Clip::new("a.mp4")),
})
.unwrap();
ed.apply(&Command::AddClip {
track,
clip: Box::new(Clip::new("b.mp4")),
})
.unwrap();
ed.commit_group();
let clips = &ed.current().video_tracks()[0].clips;
assert_eq!(clips.len(), 3);
assert_ne!(clips[1].id, clips[2].id);
}
#[test]
fn editor_amend_during_group_should_fold_into_the_gesture() {
let mut ed = Editor::new(timeline(30.0));
ed.begin_group();
ed.apply(&set_fps(24.0)).unwrap();
ed.amend(&set_fps(48.0)).unwrap(); assert!((ed.current().frame_rate() - 48.0).abs() < f64::EPSILON);
ed.commit_group();
let prev = ed.undo().unwrap();
assert!((prev.frame_rate() - 30.0).abs() < f64::EPSILON);
assert!(!ed.can_undo());
}
#[test]
fn editor_nested_begin_group_should_not_restart_the_gesture() {
let mut ed = Editor::new(timeline(30.0));
ed.begin_group();
ed.apply(&set_fps(24.0)).unwrap(); ed.begin_group(); assert!(
(ed.current().frame_rate() - 24.0).abs() < f64::EPSILON,
"a second begin_group must not discard in-progress edits"
);
ed.commit_group();
let prev = ed.undo().unwrap();
assert!((prev.frame_rate() - 30.0).abs() < f64::EPSILON);
}
#[test]
fn editor_set_clip_should_be_undoable() {
let mut ed = Editor::new(timeline(30.0));
let id = ed.current().video_tracks()[0].clips[0].id;
let mut patch = Clip::new("patched.mp4");
patch.brightness = 0.5;
ed.apply(&Command::SetClip {
clip: id,
value: Box::new(patch),
})
.unwrap();
assert_eq!(
ed.current().video_tracks()[0].clips[0]
.source_path()
.and_then(std::path::Path::to_str),
Some("patched.mp4")
);
let prev = ed.undo().unwrap();
assert_eq!(
prev.video_tracks()[0].clips[0]
.source_path()
.and_then(std::path::Path::to_str),
Some("a.mp4"),
"undo restores the pre-patch clip"
);
}
#[test]
fn editor_split_clip_should_be_undoable() {
use std::time::Duration;
let mut ed = Editor::new(timeline(30.0));
let id = ed.current().video_tracks()[0].clips[0].id;
ed.apply(&Command::SplitClip {
clip: id,
at: Duration::from_secs(1),
})
.unwrap();
assert_eq!(ed.current().video_tracks()[0].clips.len(), 2);
let prev = ed.undo().unwrap();
assert_eq!(
prev.video_tracks()[0].clips.len(),
1,
"undo restores the single clip"
);
}
#[test]
fn editor_move_clip_to_track_should_be_undoable() {
use std::time::Duration;
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![Clip::new("a.mp4")])
.video_track(vec![])
.build()
.unwrap();
let mut ed = Editor::new(t);
let clip_id = ed.current().video_tracks()[0].clips[0].id;
let to = ed.current().video_tracks()[1].id;
ed.apply(&Command::MoveClipToTrack {
clip: clip_id,
to,
offset: Duration::from_secs(2),
})
.unwrap();
assert!(ed.current().video_tracks()[0].clips.is_empty());
let prev = ed.undo().unwrap();
assert_eq!(
prev.video_tracks()[0].clips.len(),
1,
"undo restores the clip on the original track"
);
}
#[test]
fn editor_ripple_delete_should_be_one_undoable_step() {
use std::time::Duration;
let a = Clip::new("a.mp4")
.trim(Duration::ZERO, Duration::from_secs(4))
.offset(Duration::ZERO);
let b = Clip::new("b.mp4").offset(Duration::from_secs(4));
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![a, b])
.build()
.unwrap();
let mut ed = Editor::new(t);
let a_id = ed.current().video_tracks()[0].clips[0].id;
ed.apply(&Command::RippleDelete { clip: a_id }).unwrap();
assert_eq!(ed.current().video_tracks()[0].clips.len(), 1);
assert_eq!(
ed.current().video_tracks()[0].clips[0].offset,
Duration::ZERO,
"b shifted left to close the gap"
);
let prev = ed.undo().unwrap();
assert_eq!(prev.video_tracks()[0].clips.len(), 2);
assert_eq!(
prev.video_tracks()[0].clips[1].offset,
Duration::from_secs(4)
);
}
}