Project panel: dragging one of several selected items places them all, back to back, in one undo step

timeline.place takes items: [...] and file.newSequence takes fromItems: [...]; both run as
one undo step through the new Session::edit_group, which rolls back if any item fails.
Files dropped from the OS use the same command instead of one timeline.place per file,
so they are one undo step too.
This commit is contained in:
awe 2026-10-11 00:22:02 +07:00
parent 98ba9acbce
commit cbd06b6c9a
7 changed files with 366 additions and 134 deletions

View File

@ -577,6 +577,110 @@ pub(crate) fn ensure_audio_tracks(p: &mut Project, seq_id: ItemId, first: usize,
Ok(p.sequence(seq_id).map(|q| q.audio_tracks.iter().map(|t| t.id).collect()).unwrap_or_default())
}
/// `timeline.place` for one item (`item`).
fn place_one(s: &mut Session, p: &Value) -> Result<Value> {
let stream_flag = |key: &str| match p.get(key) {
None => Ok(true),
Some(Value::Bool(v)) => Ok(*v),
_ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))),
};
let (video, audio) = (stream_flag("video")?, stream_flag("audio")?);
if !video && !audio {
return Err(bad("timeline.place", "enable video, audio, or both"));
}
let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?;
let at = time_p(s, p, "").unwrap_or(s.playhead());
let tg = s.targeting();
let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?;
let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t));
// `track` is where the picture goes and `audioTrack` where the sound goes. A caller that
// names an audio track as `track` asks for the sound alone, on that track.
let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) {
(Some(t), Some(_)) if is_audio(t) => {
return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once"));
}
(_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")),
(Some(t), None) if is_audio(t) && !pi.has_audio() => {
return Err(bad("timeline.place", "`track` names an audio track and this item has no sound"));
}
(Some(t), None) if is_audio(t) => (None, Some(t)),
(Some(_), None) if !pi.has_video() => {
return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track"));
}
(v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)),
};
let v = v.filter(|_| video);
let a = a.filter(|_| audio);
if p.get("video").is_some() && video && pi.has_video() && v.is_none() {
return Err(bad("timeline.place", "no video destination: add or enable a video track"));
}
if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() {
return Err(bad("timeline.place", "no audio destination: add or enable an audio track"));
}
let full = match &pi.kind {
// Settings ▸ Timeline ▸ Still Image Default Duration
ItemKind::Media(m)
if matches!(m.info.kind, filmcraft_media::MediaKind::Still)
|| (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) =>
{
s.prefs.timeline.still_duration(s.sequence_rate())
}
_ => pi.duration(),
};
let (mi, mo) = match &pi.kind {
ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())),
ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())),
_ => (None, None),
};
let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default();
let dur = p.get("duration").and_then(Value::as_i64).map(Tick).unwrap_or_else(|| mo.unwrap_or(full) - sin);
let ids = place_item(s, item, TimeRange::new(sin, dur), at, v, a, bool_p(p, "insert").unwrap_or(false), "Place Clip", None)?;
Ok(json!({"clips": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
}
/// Most items one `timeline.place` with `items` takes: a larger list is refused, not cut.
const MAX_PLACE_ITEMS: usize = 10_000;
/// `timeline.place` for `items`: the first at the given time, each next one where the previous one
/// ends, all as one undo step. `track`, `audioTrack`, `insert`, `video` and `audio` apply to every
/// item; `sourceIn` and `duration` name one item's range, so they need `item`.
fn place_items(s: &mut Session, p: &Value, items: &Value) -> Result<Value> {
let ids: Vec<ItemId> = items
.as_array()
.ok_or_else(|| bad("timeline.place", "`items` must be a list of item ids"))?
.iter()
.map(|v| v.as_u64().map(ItemId).ok_or_else(|| bad("timeline.place", "`items` must be a list of item ids")))
.collect::<Result<_>>()?;
if p.get("item").is_some() || p.get("sourceIn").is_some() || p.get("duration").is_some() {
return Err(bad("timeline.place", "give `item` or `items`, and no `sourceIn` or `duration` with `items`"));
}
if ids.is_empty() || ids.len() > MAX_PLACE_ITEMS {
return Err(bad("timeline.place", format!("`items` needs 1 to {MAX_PLACE_ITEMS} item ids")));
}
let mut at = time_p(s, p, "").unwrap_or(s.playhead());
s.edit_group("Place Clips", |s| {
let mut placed = Vec::new();
for item in ids {
let mut one = p.clone();
if let Some(o) = one.as_object_mut() {
o.remove("items");
for k in ["time", "frame", "seconds"] {
o.remove(k);
}
o.insert("item".into(), json!(item.0));
o.insert("time".into(), json!(at.0));
}
let r = place_one(s, &one)?;
let clips: Vec<ClipId> = r["clips"].as_array().map(|a| a.iter().filter_map(|c| c.as_u64().map(ClipId)).collect()).unwrap_or_default();
if let Some(end) = s.active_sequence().and_then(|q| clips.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.end())).max()) {
at = end;
}
placed.push(json!(clips.iter().map(|c| c.0).collect::<Vec<_>>()));
}
Ok(json!({"clips": placed.iter().flat_map(|c| c.as_array().cloned().unwrap_or_default()).collect::<Vec<_>>(), "placed": placed}))
})
}
/// Place a project item on the timeline (drag from Project, or Insert/Overwrite from source).
pub(crate) fn place_item(
s: &mut Session,
@ -774,11 +878,22 @@ fn build() -> Vec<CommandSpec> {
"Sequence…",
["File", "New"],
Some("Cmd+N"),
r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?}"#,
r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?,"fromItems":[itemId]?}"#,
always,
|s, p| {
|s, p| s.edit_group("New Sequence", |s| {
// `fromItems` is `fromItem` for a list: the first sets the sequence, the rest follow it back to back
let mut from_ids: Vec<ItemId> = match p.get("fromItems") {
None => Vec::new(),
Some(l) => l
.as_array()
.and_then(|a| a.iter().map(|v| v.as_u64().map(ItemId)).collect())
.ok_or_else(|| bad("file.newSequence", "`fromItems` must be a list of item ids"))?,
};
if let Some(one) = item_p(p, "fromItem") {
from_ids.insert(0, one);
}
let mut st = SequenceSettings::default();
if let Some(from) = item_p(p, "fromItem").and_then(|i| s.project.item(i)).and_then(|i| i.as_media()) {
if let Some(from) = from_ids.first().and_then(|i| s.project.item(*i)).and_then(|i| i.as_media()) {
st = default_seq_settings_for(from);
}
if let Some(w) = checked_u32_p(p, "width", "file.newSequence")? {
@ -828,14 +943,19 @@ fn build() -> Vec<CommandSpec> {
}
Ok(id)
})?;
if let Some(from) = item_p(p, "fromItem") {
if let Some((&from, rest)) = from_ids.split_first() {
let dur = s.project.item(from).map(|i| i.duration()).unwrap_or_default();
let tg = s.targeting();
place_item(s, from, TimeRange::new(Tick::ZERO, dur), Tick::ZERO, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?;
let ids =
place_item(s, from, TimeRange::new(Tick::ZERO, dur), Tick::ZERO, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?;
if !rest.is_empty() {
let end = s.active_sequence().and_then(|q| ids.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.end())).max()).unwrap_or_default();
place_items(s, &json!({"time": end.0}), &json!(rest.iter().map(|i| i.0).collect::<Vec<_>>()))?;
}
}
s.events.push(crate::Event::OpenSequence(id));
Ok(json!({"sequence": id.0}))
}
})
),
cmd!("file.newBin", "Bin", ["File", "New"], Some("Cmd+B"), r#"{"name":str,"parent":binId?}"#, always, |s, p| {
let n = str_p(p, "name").unwrap_or("New Bin").to_string();
@ -2074,66 +2194,11 @@ fn build() -> Vec<CommandSpec> {
"Place Clip",
[],
None,
r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#,
r#"{"item":id|"items":[id] (back to back, one undo step),"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#,
has_seq,
|s, p| {
let stream_flag = |key: &str| match p.get(key) {
None => Ok(true),
Some(Value::Bool(v)) => Ok(*v),
_ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))),
};
let (video, audio) = (stream_flag("video")?, stream_flag("audio")?);
if !video && !audio {
return Err(bad("timeline.place", "enable video, audio, or both"));
}
let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?;
let at = time_p(s, p, "").unwrap_or(s.playhead());
let tg = s.targeting();
let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?;
let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t));
// `track` is where the picture goes and `audioTrack` where the sound goes. A caller that
// names an audio track as `track` asks for the sound alone, on that track.
let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) {
(Some(t), Some(_)) if is_audio(t) => {
return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once"));
}
(_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")),
(Some(t), None) if is_audio(t) && !pi.has_audio() => {
return Err(bad("timeline.place", "`track` names an audio track and this item has no sound"));
}
(Some(t), None) if is_audio(t) => (None, Some(t)),
(Some(_), None) if !pi.has_video() => {
return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track"));
}
(v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)),
};
let v = v.filter(|_| video);
let a = a.filter(|_| audio);
if p.get("video").is_some() && video && pi.has_video() && v.is_none() {
return Err(bad("timeline.place", "no video destination: add or enable a video track"));
}
if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() {
return Err(bad("timeline.place", "no audio destination: add or enable an audio track"));
}
let full = match &pi.kind {
// Settings ▸ Timeline ▸ Still Image Default Duration
ItemKind::Media(m)
if matches!(m.info.kind, filmcraft_media::MediaKind::Still)
|| (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) =>
{
s.prefs.timeline.still_duration(s.sequence_rate())
}
_ => pi.duration(),
};
let (mi, mo) = match &pi.kind {
ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())),
ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())),
_ => (None, None),
};
let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default();
let dur = p.get("duration").and_then(Value::as_i64).map(Tick).unwrap_or_else(|| mo.unwrap_or(full) - sin);
let ids = place_item(s, item, TimeRange::new(sin, dur), at, v, a, bool_p(p, "insert").unwrap_or(false), "Place Clip", None)?;
Ok(json!({"clips": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
|s, p| match p.get("items") {
Some(items) => place_items(s, p, items),
None => place_one(s, p),
}
),
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| {

View File

@ -799,6 +799,39 @@ impl Session {
Ok(r)
}
/// Run several undoable edits as one undo step labelled `label`. If `f` fails, every edit it made
/// is rolled back and the history is as before.
pub fn edit_group<R>(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
let (project, state, undo, redo, limit) =
(self.project.clone(), self.state.clone(), self.history.undo.len(), self.history.redo.clone(), self.history.limit);
// keep every step the group makes, however many, so the first one can be found afterwards
self.history.limit = usize::MAX;
let r = f(self);
self.history.limit = limit;
let first = self.history.undo.get(undo).map(|e| e.1.clone());
match (r, first) {
(Ok(v), Some(first)) => {
self.history.undo.truncate(undo);
self.history.undo.push((label.to_string(), first));
if self.history.undo.len() > limit {
self.history.undo.remove(0);
}
self.history.merge_key = None;
Ok(v)
}
(Ok(v), None) => Ok(v),
(Err(e), _) => {
self.history.undo.truncate(undo);
self.history.redo = redo;
self.history.merge_key = None;
self.project = project;
self.state = state;
self.bump();
Err(e)
}
}
}
/// Edit the active sequence with the edit-algebra context.
pub fn edit_sequence<R>(&mut self, label: &str, f: impl FnOnce(&mut Sequence, &mut EditCtx, &mut EditorState) -> Result<R>) -> Result<R> {
self.edit_sequence_as(label, None, f)
@ -1088,6 +1121,8 @@ mod panels_tests;
#[cfg(test)]
mod par_tests;
#[cfg(test)]
mod place_items_tests;
#[cfg(test)]
mod presets_tests;
#[cfg(test)]
mod previews_tests;

View File

@ -0,0 +1,109 @@
//! `timeline.place` with `items` and `file.newSequence` with `fromItems`: several items back to
//! back, as one undo step.
use filmcraft_project::ItemId;
use serde_json::json;
use crate::Session;
/// A session on an empty sequence, and the ids of three media items with picture and sound.
fn three() -> (Session, Vec<ItemId>) {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let items: Vec<ItemId> = s.project.items.values().filter(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).take(3).collect();
assert!(items.len() >= 2, "the demo project has media to place");
s.execute("file.newSequence", json!({"name": "Back to back", "video": 2, "audio": 2})).unwrap();
(s, items)
}
fn ends(s: &Session) -> Vec<(i64, i64)> {
let q = s.active_sequence().unwrap();
q.video_tracks[0].items.iter().map(|i| (i.start.0, i.end().0)).collect()
}
/// Dragging several selected items placed them one by one, each one an undo step.
#[test]
fn items_land_back_to_back_in_one_undo_step() {
let (mut s, items) = three();
let steps = s.history.undo.len();
let r = s.execute("timeline.place", json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "time": 0})).unwrap();
assert_eq!(r["placed"].as_array().unwrap().len(), items.len());
let spans = ends(&s);
assert_eq!(spans.len(), items.len());
assert_eq!(spans[0].0, 0);
for w in spans.windows(2) {
assert_eq!(w[0].1, w[1].0, "each item starts where the one before ends");
}
assert_eq!(s.history.undo.len(), steps + 1, "one undo step for all of them");
assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Place Clips"));
s.undo();
assert!(ends(&s).is_empty(), "one undo takes them all off");
s.redo();
assert_eq!(ends(&s), spans);
}
/// One bad item in the list leaves the sequence and the history as they were.
#[test]
fn a_failing_item_places_nothing() {
let (mut s, items) = three();
let steps = s.history.undo.len();
let list = vec![items[0].0, u64::MAX - 1];
assert!(s.execute("timeline.place", json!({"items": list, "time": 0})).is_err());
assert!(ends(&s).is_empty());
assert_eq!(s.history.undo.len(), steps);
}
/// `items` is checked: not a list, empty, mixed with `item`, or with one item's range.
#[test]
fn items_are_checked() {
let (mut s, items) = three();
let one = items[0].0;
for p in [
json!({"items": one}),
json!({"items": []}),
json!({"items": ["x"]}),
json!({"items": [one], "item": one}),
json!({"items": [one], "duration": 5}),
json!({"items": [one], "sourceIn": 5}),
] {
assert!(s.execute("timeline.place", p.clone()).is_err(), "{p}");
}
assert!(ends(&s).is_empty());
}
/// A new sequence from several items is one undo step: the sequence and its clips go together.
#[test]
fn new_sequence_from_items_is_one_undo_step() {
let (mut s, items) = three();
let steps = s.history.undo.len();
let sequences = s.project.sequences().count();
s.execute("file.newSequence", json!({"fromItems": items.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
assert_eq!(ends(&s).len(), items.len());
assert_eq!(s.history.undo.len(), steps + 1);
s.undo();
assert_eq!(s.project.sequences().count(), sequences);
assert!(s.execute("file.newSequence", json!({"fromItems": 3})).is_err());
}
/// Times at the edge of the number range are refused or placed, never a panic, and a refusal
/// leaves the sequence and the history as they were.
#[test]
fn hostile_times_never_panic_and_leave_nothing_half_placed() {
let (mut s, items) = three();
let ids: Vec<u64> = items.iter().map(|i| i.0).collect();
for t in [i64::MAX, i64::MAX - 5, i64::MIN, -1] {
let steps = s.history.undo.len();
let r = s.execute("timeline.place", json!({"items": ids, "time": t}));
if r.is_err() {
assert_eq!(s.history.undo.len(), steps, "{t}");
assert!(ends(&s).is_empty(), "{t}");
} else {
assert_eq!(s.history.undo.len(), steps + 1, "{t}");
s.undo();
}
}
// zero-length and many items: capped, not unbounded
let many = vec![items[0].0; 10_001];
assert!(s.execute("timeline.place", json!({"items": many, "time": 0})).is_err());
assert!(ends(&s).is_empty());
}

View File

@ -158,6 +158,12 @@ pub fn dragged_project_item(ui: &egui::Ui) -> Option<ItemId> {
_ => None,
}
}
/// The items a drag from the Project panel carries: the whole selection when the dragged item is
/// one of several selected, otherwise just the dragged item.
pub fn dragged_selection(app: &FilmcraftApp, item: ItemId) -> Vec<ItemId> {
let sel = &app.session.state.project_selection;
if sel.len() > 1 && sel.contains(&item) { sel.clone() } else { vec![item] }
}
pub fn dragged_effect(ui: &egui::Ui) -> Option<String> {
match payload(ui) {
Some(DragPayload::Effect(e)) => Some(e),
@ -174,7 +180,10 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) {
let ctx = ui.ctx();
if let Some(p) = ctx.pointer_hover_pos() {
let label = match &pl {
DragPayload::Item(i) => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(),
DragPayload::Item(i) => match dragged_selection(app, *i).len() {
1 => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(),
n => format!("{n} items"),
},
DragPayload::Source(s) => {
let name = app.session.project.item(s.item).map(|i| i.name.as_str()).unwrap_or("Source");
let mode = match (s.video, s.audio) {

View File

@ -568,7 +568,11 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& ui.rect_contains_pointer(rect)
&& ui.input(|i| i.pointer.any_released())
{
let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0}));
// a multi-selection makes the sequence from its first item and follows it, back to back, in one undo step
let items: Vec<u64> = crate::panels::dragged_selection(app, item).iter().map(|i| i.0).collect();
if let Err(e) = app.session.execute("file.newSequence", json!({"fromItems": items})) {
app.ui.status = e.to_string();
}
}
}
@ -2315,15 +2319,23 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)),
TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)),
};
let mut params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": mods.command});
// dragging one of several selected project items places them all, back to back, as one edit
let items = match source {
None => crate::panels::dragged_selection(app, item),
Some(_) => vec![item],
};
let mut params = json!({"track": vt, "audioTrack": at, "time": t.0, "insert": mods.command});
match items.as_slice() {
[one] => params["item"] = json!(one.0),
many => params["items"] = json!(many.iter().map(|i| i.0).collect::<Vec<_>>()),
}
if let Some(source) = source {
params["sourceIn"] = json!(source.range.start.0);
params["duration"] = json!(source.range.duration.0);
params["video"] = json!(source.video);
params["audio"] = json!(source.audio);
}
let r = app.session.execute("timeline.place", params);
if let Err(e) = r {
if let Err(e) = app.session.execute("timeline.place", params) {
app.ui.status = e.to_string();
}
crate::panels::clear_drag(ui);
@ -2416,20 +2428,12 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)),
TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)),
};
let still = app.session.prefs.timeline.still_duration(rate);
let durations: Vec<Tick> = items
.iter()
.map(|&item| {
let is_still = app.session.project.item(item).and_then(|i| i.as_media()).is_some_and(|m| m.info.kind == filmcraft_media::MediaKind::Still);
app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still)
})
.collect();
let start = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]);
for (item, t) in items.iter().zip(chain_starts(start, &durations)) {
let params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": false});
if let Err(e) = app.session.execute("timeline.place", params) {
app.ui.status = e.to_string();
}
// one command, one undo step: the files go end to end from the drop point
let ids: Vec<u64> = items.iter().map(|i| i.0).collect();
let params = json!({"items": ids, "track": vt, "audioTrack": at, "time": start.0, "insert": false});
if let Err(e) = app.session.execute("timeline.place", params) {
app.ui.status = e.to_string();
}
}
}
@ -2451,19 +2455,6 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
app.auto.add("timeline.tracks", layout.content, "tracks");
}
/// Start tick of each clip when the clips are laid end to end from `start`. Saturates rather than overflows.
pub(crate) fn chain_starts(start: Tick, durations: &[Tick]) -> Vec<Tick> {
let mut t = start;
durations
.iter()
.map(|d| {
let at = t;
t = Tick(t.0.saturating_add(d.0.max(0)));
at
})
.collect()
}
#[cfg(test)]
mod waveform_tests {
use super::waveform_display_gain;
@ -2486,41 +2477,3 @@ mod waveform_tests {
assert!((db(waveform_display_gain(1.0, -6.0)) - (-6.0)).abs() < 0.1);
}
}
#[cfg(test)]
mod chain_starts_tests {
use super::chain_starts;
use filmcraft_time::Tick;
#[test]
fn empty_list_has_no_starts() {
assert!(chain_starts(Tick(100), &[]).is_empty());
}
#[test]
fn one_clip_starts_at_the_drop_point() {
assert_eq!(chain_starts(Tick(7), &[Tick(30)]), vec![Tick(7)]);
}
#[test]
fn several_clips_are_laid_end_to_end() {
let durations = [Tick(10), Tick(25), Tick(5)];
assert_eq!(chain_starts(Tick(100), &durations), vec![Tick(100), Tick(110), Tick(135)]);
}
#[test]
fn start_near_i64_max_saturates_without_panicking() {
let starts = chain_starts(Tick(i64::MAX - 5), &[Tick(10), Tick(10), Tick(10)]);
assert_eq!(starts, vec![Tick(i64::MAX - 5), Tick(i64::MAX), Tick(i64::MAX)]);
}
#[test]
fn zero_length_clips_share_one_start() {
assert_eq!(chain_starts(Tick(42), &[Tick(0), Tick(0), Tick(9)]), vec![Tick(42), Tick(42), Tick(42)]);
}
#[test]
fn negative_durations_count_as_zero() {
assert_eq!(chain_starts(Tick(5), &[Tick(-3), Tick(4)]), vec![Tick(5), Tick(5)]);
}
}

View File

@ -626,3 +626,40 @@ fn media_browser_drag_imports_into_the_project_panel() {
assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline");
let _ = std::fs::remove_dir_all(&dir);
}
/// Dragging one of several selected Project items onto the Timeline places all of them, back to
/// back, as one undo step; an item outside the selection goes alone.
#[test]
fn dragging_a_selection_to_the_timeline_places_every_item() {
let mut d = Driver::demo();
let (bin, items) = d.footage();
assert!(items.len() >= 3);
d.exec("file.newSequence", json!({"name": "Drop", "video": 2, "audio": 2}));
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
let card = |i: u64| format!("project.item.{i}");
d.wait_for(&card(items[0]));
let clips = |d: &mut Driver| d.app().session.active_sequence().map(|q| q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>()).unwrap_or(0);
let steps = d.app().session.history.undo.len();
let drop = json!({"id": "timeline.tracks", "fx": 0.3, "fy": 0.1});
// an item that is not selected: one clip
d.exec("project.select", json!({"items": [items[2]]}));
d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16}));
d.frames(4);
assert_eq!(clips(&mut d), 1, "an unselected item goes alone");
d.exec("edit.undo", json!({}));
// two of the selection: both, back to back, in one undo step
d.exec("project.select", json!({"items": [items[0], items[1]]}));
d.frames(2);
d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16}));
d.frames(4);
assert_eq!(clips(&mut d), 2, "the whole selection is placed");
let mut v1: Vec<(i64, i64)> =
d.app().session.active_sequence().unwrap().video_tracks.iter().flat_map(|t| t.items.iter()).map(|i| (i.start.0, i.end().0)).collect();
v1.sort();
assert_eq!(v1[0].1, v1[1].0, "back to back");
assert_eq!(d.app().session.history.undo.len(), steps + 1, "the drag is one undo step");
d.exec("edit.undo", json!({}));
assert_eq!(clips(&mut d), 0);
}

View File

@ -152,3 +152,27 @@ fn with_drop_import_off_the_file_is_only_imported() {
assert_eq!(h.state().session.project.items.len(), items_before + 1, "the still is imported to the bin");
assert_eq!(clip_counts(&h), (0, 0), "status: {}", h.state().ui.status);
}
/// Several files dropped at once land end to end, and one undo takes all of them off the Timeline.
#[test]
fn several_files_dropped_together_are_placed_in_one_undo_step() {
let mut h = harness();
let at = point_on_row(&h, "timeline.track.V1.target");
pointer_to(&mut h, at);
for name in ["drop_many_a.png", "drop_many_b.png", "drop_many_c.png"] {
h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still(name))));
}
step(&mut h);
h.input_mut().dropped_files.clear();
step(&mut h);
assert_eq!(clip_counts(&h), (3, 0), "status: {}", h.state().ui.status);
let spans: Vec<(i64, i64)> = {
let q = h.state().session.active_sequence().unwrap();
let mut v: Vec<_> = q.video_tracks.iter().flat_map(|t| t.items.iter()).map(|i| (i.start.0, i.end().0)).collect();
v.sort();
v
};
assert!(spans.windows(2).all(|w| w[0].1 == w[1].0), "end to end: {spans:?}");
h.state_mut().session.execute("edit.undo", serde_json::json!({})).unwrap();
assert_eq!(clip_counts(&h), (0, 0), "one undo removes every placed clip");
}