mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-30 04:57:19 -06:00
235 lines
11 KiB
Rust
235 lines
11 KiB
Rust
//! Selecting a gap between clips and closing it with Delete / Backspace / Ripple Delete (#648, #668):
|
|
//! `timeline.selectGap` selects the empty time on one track, `edit.clear` and `edit.rippleDelete`
|
|
//! close it as one "Ripple Delete" undo step.
|
|
|
|
use filmcraft_project::ClipId;
|
|
use filmcraft_time::Tick;
|
|
use serde_json::json;
|
|
|
|
use crate::{GapSelection, Session};
|
|
|
|
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
|
|
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
|
|
}
|
|
|
|
/// Two 48-frame clips (pictures on V1, linked sounds on A1) placed at `frames`.
|
|
fn two_clips(s: &mut Session, frames: [i64; 2]) -> [(ClipId, ClipId); 2] {
|
|
s.execute("file.openDemoProject", json!({})).unwrap();
|
|
s.execute("file.newSequence", json!({"name": "Gaps", "video": 1, "audio": 1})).unwrap();
|
|
let rate = s.sequence_rate();
|
|
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
|
for f in frames {
|
|
s.execute("timeline.place", json!({"item": item.0, "frame": f, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
|
|
}
|
|
s.execute("edit.deselectAll", json!({})).unwrap();
|
|
let q = s.active_sequence().unwrap();
|
|
[0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
|
|
}
|
|
|
|
#[test]
|
|
fn clicking_between_two_clips_selects_the_gap_and_delete_closes_it() {
|
|
let mut s = Session::default();
|
|
let [(picture1, _), (picture2, sound2)] = two_clips(&mut s, [0, 96]);
|
|
let rate = s.sequence_rate();
|
|
let f = |n: i64| rate.tick_of(n);
|
|
let (f48, f60, f96) = (f(48), f(60), f(96));
|
|
s.execute("timeline.select", json!({"clips": [picture1.0]})).unwrap();
|
|
let before = s.project.clone();
|
|
|
|
let r = s.execute("timeline.selectGap", json!({"track": "V1", "time": f60.0})).unwrap();
|
|
assert_eq!(r, json!({"track": s.active_sequence().unwrap().video_tracks[0].id.0, "start": f48.0, "end": f96.0}));
|
|
assert!(s.state.selection.is_empty(), "the gap replaces the clip selection");
|
|
assert!(s.is_enabled("edit.clear") && s.is_enabled("edit.rippleDelete"), "Delete and Ripple Delete apply to the gap");
|
|
|
|
// Delete / Backspace
|
|
s.execute("edit.clear", json!({})).unwrap();
|
|
assert_eq!(range(&s, picture2), (f48, f96), "the next clip moves up into the gap");
|
|
assert_eq!(range(&s, sound2), (f48, f96), "and its sound with it");
|
|
assert_eq!(range(&s, picture1).0, Tick::ZERO, "the clip before stays");
|
|
assert!(s.state.gap_selection.is_none(), "the gap is gone");
|
|
s.active_sequence().unwrap().check().unwrap();
|
|
|
|
assert_eq!(s.undo().as_deref(), Some("Ripple Delete"), "one undo step");
|
|
assert_eq!(*s.project, *before);
|
|
}
|
|
|
|
#[test]
|
|
fn ripple_delete_closes_a_selected_gap_too() {
|
|
let mut s = Session::default();
|
|
let [_, (picture2, _)] = two_clips(&mut s, [0, 96]);
|
|
let rate = s.sequence_rate();
|
|
let f = |n: i64| rate.tick_of(n);
|
|
s.execute("timeline.selectGap", json!({"track": "A1", "frame": 50})).unwrap();
|
|
s.execute("edit.rippleDelete", json!({})).unwrap();
|
|
assert_eq!(range(&s, picture2).0, f(48));
|
|
}
|
|
|
|
#[test]
|
|
fn the_empty_time_before_the_first_clip_is_a_gap() {
|
|
let mut s = Session::default();
|
|
let [(picture1, _), (picture2, _)] = two_clips(&mut s, [24, 72]);
|
|
let rate = s.sequence_rate();
|
|
let f = |n: i64| rate.tick_of(n);
|
|
let r = s.execute("timeline.selectGap", json!({"track": "V1", "frame": 5})).unwrap();
|
|
assert_eq!((r["start"].clone(), r["end"].clone()), (json!(0), json!(f(24).0)));
|
|
s.execute("edit.clear", json!({})).unwrap();
|
|
assert_eq!((range(&s, picture1).0, range(&s, picture2).0), (Tick::ZERO, f(48)));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_time_after_the_last_clip_is_not_a_gap() {
|
|
let mut s = Session::default();
|
|
two_clips(&mut s, [0, 96]);
|
|
assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 400})).is_err());
|
|
assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 10})).is_err(), "inside a clip");
|
|
assert!(s.state.gap_selection.is_none());
|
|
assert!(!s.is_enabled("edit.clear"), "nothing to delete");
|
|
}
|
|
|
|
#[test]
|
|
fn selecting_a_clip_or_deselecting_all_drops_the_gap() {
|
|
let mut s = Session::default();
|
|
let [(picture1, _), _] = two_clips(&mut s, [0, 96]);
|
|
s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap();
|
|
s.execute("timeline.select", json!({"clips": [picture1.0]})).unwrap();
|
|
assert!(s.state.gap_selection.is_none());
|
|
s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap();
|
|
s.execute("edit.deselectAll", json!({})).unwrap();
|
|
assert!(s.state.gap_selection.is_none());
|
|
}
|
|
|
|
/// An edit that fills the gap leaves no gap selected; Delete then removes nothing it should not.
|
|
#[test]
|
|
fn an_edit_that_changes_the_gap_drops_it() {
|
|
let mut s = Session::default();
|
|
let [(picture1, _), (picture2, _)] = two_clips(&mut s, [0, 96]);
|
|
let rate = s.sequence_rate();
|
|
let f = |n: i64| rate.tick_of(n);
|
|
s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap();
|
|
s.execute("timeline.trim", json!({"clip": picture1.0, "edge": "out", "mode": "regular", "deltaFrames": 12})).unwrap();
|
|
assert!(s.state.gap_selection.is_none(), "the gap changed");
|
|
assert!(s.execute("edit.clear", json!({})).is_err());
|
|
assert_eq!(range(&s, picture2).0, f(96), "nothing moved");
|
|
}
|
|
|
|
/// A stale selection (set from outside, as the control channel can) is refused, not acted on.
|
|
#[test]
|
|
fn a_stale_gap_is_refused() {
|
|
let mut s = Session::default();
|
|
let [_, (picture2, _)] = two_clips(&mut s, [0, 96]);
|
|
let rate = s.sequence_rate();
|
|
let f = |n: i64| rate.tick_of(n);
|
|
let track = s.active_sequence().unwrap().video_tracks[0].id;
|
|
s.state.gap_selection = Some(GapSelection { track, range: filmcraft_time::TimeRange::from_bounds(f(10), f(20)) });
|
|
assert!(s.execute("edit.clear", json!({})).is_err());
|
|
assert!(s.state.gap_selection.is_none());
|
|
assert_eq!(range(&s, picture2).0, f(96));
|
|
}
|
|
|
|
#[test]
|
|
fn a_gap_on_a_locked_track_cannot_be_selected_or_closed() {
|
|
let mut s = Session::default();
|
|
let [_, (picture2, _)] = two_clips(&mut s, [0, 96]);
|
|
let rate = s.sequence_rate();
|
|
let f = |n: i64| rate.tick_of(n);
|
|
s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap();
|
|
assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).is_err());
|
|
// Sequence ▸ Close Gap moved the other tracks while the locked one stayed put
|
|
assert!(s.execute("sequence.closeGap", json!({"track": "V1", "frame": 60})).is_err());
|
|
assert_eq!(range(&s, picture2).0, f(96));
|
|
let sound2 = s.active_sequence().unwrap().audio_tracks[0].items[1].id;
|
|
assert_eq!(range(&s, sound2).0, f(96), "the unlocked sound stays in sync");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_select_gap_parameters_are_errors() {
|
|
let mut s = Session::default();
|
|
assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 1})).is_err(), "no sequence");
|
|
two_clips(&mut s, [0, 96]);
|
|
for p in [
|
|
json!({}),
|
|
json!({"track": "V1"}),
|
|
json!({"frame": 60}),
|
|
json!({"track": "V99", "frame": 60}),
|
|
json!({"track": 999_999, "frame": 60}),
|
|
json!({"track": "V1", "time": i64::MAX}),
|
|
json!({"track": "V1", "time": i64::MIN}),
|
|
json!({"track": "V1", "frame": i64::MAX}),
|
|
json!({"track": "V1", "seconds": f64::MAX}),
|
|
json!({"track": [], "frame": "x"}),
|
|
] {
|
|
let _ = s.execute("timeline.selectGap", p);
|
|
}
|
|
assert!(s.state.gap_selection.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn the_gap_selection_is_part_of_the_ui_state() {
|
|
let mut s = Session::default();
|
|
two_clips(&mut s, [0, 96]);
|
|
s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap();
|
|
let v = serde_json::to_value(&s.state).unwrap();
|
|
let back: crate::EditorState = serde_json::from_value(v).unwrap();
|
|
assert_eq!(back.gap_selection, s.state.gap_selection);
|
|
}
|
|
|
|
#[test]
|
|
fn close_gap_is_available_for_custom_shortcuts() {
|
|
let mut s = Session::default();
|
|
let rows = s.execute("shortcuts.list", json!({"query":"Close Gap"})).unwrap();
|
|
assert_eq!(rows.as_array().unwrap().len(), 1);
|
|
assert_eq!(rows[0]["id"], "sequence.closeGap");
|
|
assert!(rows[0]["shortcuts"].as_array().unwrap().is_empty(), "no new default binding");
|
|
s.execute("shortcuts.set", json!({"command":"sequence.closeGap","keys":"Alt+Shift+F8"})).unwrap();
|
|
assert_eq!(s.shortcuts.primary("sequence.closeGap").as_deref(), Some("Alt+Shift+F8"));
|
|
}
|
|
|
|
#[test]
|
|
fn close_gap_without_params_closes_selected_gap_in_one_undo_step() {
|
|
let mut s = Session::default();
|
|
let [(first, _), (second, sound)] = two_clips(&mut s, [0, 96]);
|
|
let frame = s.sequence_rate().tick_of(48);
|
|
s.execute("timeline.selectGap", json!({"track":"V1","frame":60})).unwrap();
|
|
let before = serde_json::to_value(&s.project).unwrap();
|
|
let undo = s.history.undo.len();
|
|
assert_eq!(s.execute("sequence.closeGap", json!({})).unwrap(), serde_json::Value::Null);
|
|
assert_eq!(range(&s, first).0, Tick::ZERO);
|
|
assert_eq!(range(&s, second).0, frame);
|
|
assert_eq!(range(&s, sound).0, frame);
|
|
assert!(s.state.gap_selection.is_none());
|
|
assert_eq!(s.history.undo.len(), undo + 1);
|
|
let after = serde_json::to_value(&s.project).unwrap();
|
|
s.undo();
|
|
assert_eq!(serde_json::to_value(&s.project).unwrap(), before);
|
|
s.redo();
|
|
assert_eq!(serde_json::to_value(&s.project).unwrap(), after);
|
|
}
|
|
|
|
#[test]
|
|
fn close_gap_rejects_invalid_context_and_explicit_parameters_without_editing() {
|
|
let mut s = Session::default();
|
|
two_clips(&mut s, [0, 96]);
|
|
let before = s.project.clone();
|
|
let undo = s.history.undo.len();
|
|
assert!(s.execute("sequence.closeGap", json!({})).is_err(), "a shortcut needs a selected gap");
|
|
s.execute("timeline.selectGap", json!({"track":"V1","frame":60})).unwrap();
|
|
for params in [json!({"track":null}), json!({"track":false}), json!({"track":"V99","frame":60}), json!({"time":1})] {
|
|
assert!(s.execute("sequence.closeGap", params).is_err(), "invalid explicit requests must not fall back to the selected gap");
|
|
assert!(std::sync::Arc::ptr_eq(&before, &s.project));
|
|
assert_eq!(s.history.undo.len(), undo);
|
|
}
|
|
let selected = s.state.gap_selection.unwrap();
|
|
s.state.gap_selection.as_mut().unwrap().range.duration += Tick(1);
|
|
assert!(s.execute("sequence.closeGap", json!({})).is_err(), "a stale span cannot close a different gap");
|
|
assert!(s.state.gap_selection.is_none());
|
|
assert!(std::sync::Arc::ptr_eq(&before, &s.project));
|
|
assert_eq!(s.history.undo.len(), undo);
|
|
s.execute("timeline.setTrack", json!({"track":"V1","locked":true})).unwrap();
|
|
s.state.gap_selection = Some(selected);
|
|
let before = s.project.clone();
|
|
let undo = s.history.undo.len();
|
|
assert!(s.execute("sequence.closeGap", json!({})).is_err(), "locking a selected gap must protect both picture and sound");
|
|
assert!(std::sync::Arc::ptr_eq(&before, &s.project));
|
|
assert_eq!(s.history.undo.len(), undo);
|
|
}
|