diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 5147ccc71..0b5116e95 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -31,12 +31,12 @@ pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) { } /// Tools draw on the layer they create: when it was fitted to a span the playhead is outside, -/// move the playhead to the span's start so the new layer shows. The layer's timing is kept. -pub(crate) fn show_span(s: &mut Session, span: Option<(Tick, Tick)>) { +/// move `comp`'s playhead to the span's start so the new layer shows. The layer's timing is kept. +pub(crate) fn show_span(s: &mut Session, comp: ItemId, span: Option<(Tick, Tick)>) { if let Some((start, end)) = span - && !(start..end).contains(&s.time()) + && !(start..end).contains(&s.time_of(comp)) { - s.set_time(start); + s.set_time_of(comp, start); } } @@ -244,7 +244,7 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { Ok(id) })?; if edit { - show_span(s, span); + show_span(s, cid, span); } Ok(json!({"layer": id.0})) } diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index d983ce8d0..8b9cc28f2 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -364,7 +364,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { Ok((lid, group)) })?; if target.is_none() { - super::layer::show_span(s, span); + super::layer::show_span(s, cid, span); } Ok(json!({"layer": lid.0, "group": group})) } diff --git a/crates/engine/src/cmd/viewer_cmds.rs b/crates/engine/src/cmd/viewer_cmds.rs index 2840873cd..dfa6ce3e7 100644 --- a/crates/engine/src/cmd/viewer_cmds.rs +++ b/crates/engine/src/cmd/viewer_cmds.rs @@ -302,7 +302,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result { Ok((lid, group, path_uid)) })?; if target.is_none() { - super::layer::show_span(s, span); + super::layer::show_span(s, cid, span); } Ok(json!({"layer": lid.0, "group": group, "path": path_uid})) } diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index 51d87e018..8c2e87647 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -114,18 +114,23 @@ impl Session { /// editor is open, so playback with no text editor does no text copying. pub fn set_time(&mut self, t: Tick) { if let Some(c) = self.state.active_comp { - let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); - let edited = commands::text_edit::active_editor_snapshot(self); - self.state.times.insert(c, t); - if self.prefs.general.sync_time_related_items { - // Direct call: this `set_time` owns the editor snapshot for both time changes, - // so a synced move does not take a second snapshot or start a second epoch. - self.sync_related_times_inner(c, t); - } - commands::text_edit::after_external_change(self, edited.as_ref()); + self.set_time_of(c, t); } } + /// Move `comp`'s current time, as [`Session::set_time`] does for the active comp. + pub fn set_time_of(&mut self, c: ItemId, t: Tick) { + let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); + let edited = commands::text_edit::active_editor_snapshot(self); + self.state.times.insert(c, t); + if self.prefs.general.sync_time_related_items { + // Direct call: this `set_time_of` owns the editor snapshot for both time changes, + // so a synced move does not take a second snapshot or start a second epoch. + self.sync_related_times_inner(c, t); + } + commands::text_edit::after_external_change(self, edited.as_ref()); + } + /// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of /// `comp` moves the current time of the comps nested in it (through their precomp layers' /// timing) and of the comps it is nested in, so every related viewer shows the same moment. diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index f8eb8ed82..556135fff 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -424,6 +424,31 @@ fn new_layers_fit_the_work_area_when_asked() { assert_eq!(span(&s), (7.0, 7.0, 10.0), "footage and comps keep their own timing"); } +/// A tool command aimed at another comp moves that comp's playhead into its work area and +/// leaves the active comp's playhead where it was. +#[test] +fn fitted_layers_in_another_comp_move_that_comps_playhead() { + let mut s = session_with_comp(); + let active = s.active_comp_id().unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + let other = effectcraft_project::ItemId(s.execute("comp.new", json!({"name": "Other", "duration": 10, "open": false})).unwrap()["comp"].as_u64().unwrap()); + s.execute("comp.workArea", json!({"comp": other.0, "start": 2, "end": 5})).unwrap(); + s.execute("comp.workArea", json!({"start": 8, "end": 9})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + for (cmd, p) in [ + ("layer.newText", json!({"comp": other.0, "text": "a", "edit": true})), + ("shape.newShape", json!({"comp": other.0, "kind": "ellipse"})), + ("shape.newPath", json!({"comp": other.0, "vertices": [[0, 0], [10, 0], [10, 10]]})), + ] { + s.set_time_of(other, effectcraft_time::Tick::from_seconds_f64(7.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + assert_eq!(s.time_of(other).seconds().round(), 2.0, "{cmd}: the target comp's playhead"); + assert_eq!(s.time_of(active).seconds().round(), 7.0, "{cmd}: the active comp's playhead"); + } +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp();