From 34f62efbcaef1b915e8b0ff81c9b6847482bd673 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 08:11:39 +0100 Subject: [PATCH 1/6] Settings: Fit New Layers to Work Area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings ▸ General ▸ Fit New Layers to Work Area (off by default) makes new text, solid, shape, null and adjustment layers start at the work area start and end at its end. It applies to Layer ▸ New, the shape tools and the Pen tool, and wins over Create Layers at Composition Start Time. Footage and compositions keep their own length. --- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/cmd/layer/create.rs | 27 +++++++++++++-- crates/engine/src/cmd/shape_tool.rs | 9 +++-- crates/engine/src/cmd/viewer_cmds.rs | 3 +- crates/engine/src/settings/prefs/dialog.rs | 1 + crates/engine/src/settings/prefs/sections.rs | 3 ++ crates/engine/src/tests/settings.rs | 36 ++++++++++++++++++++ docs/preferences.md | 1 + 8 files changed, 76 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f48fa26f8..89cf45158 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -24,7 +24,7 @@ mod transform; use contents::{add_mask, add_shape_item, mask_props, set_text}; use create::{add_item, new_adjustment, new_null, new_shape, new_solid, new_text}; -pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place, position_p}; +pub(crate) use create::{color_p, fit_span, index_p, insert_layer, insert_layer_at, new_layer_span, place, position_p}; pub(crate) use parent::parent_fixes; use parent::set_parent; use replace::{has_replace_source, replace_source}; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 0196b5aee..14d9f1e0f 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -14,6 +14,22 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { } } +/// Settings ▸ General ▸ Fit New Layers to Work Area: the `(start, end)` that new text, solid, +/// shape, null and adjustment layers in `comp` take, or `None` for the whole composition. +pub(crate) fn new_layer_span(s: &Session, comp: &effectcraft_project::Comp) -> Option<(Tick, Tick)> { + let (start, end) = (comp.work_area.0, comp.work_area.1.min(comp.duration)); + (s.prefs.general.fit_new_layers_to_work_area && start >= Tick::ZERO && start < end).then_some((start, end)) +} + +/// Start a new layer at `span`'s start and end it at `span`'s end, when there is a span. +pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) { + if let Some((start, end)) = span { + l.start_time = start; + l.in_point = start; + l.out_point = end; + } +} + /// Insert a new layer above the selection (or at the top) and select it. pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { insert_layer_at(proj, st, cid, layer, None) @@ -89,10 +105,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect); let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() }; let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); + let span = new_layer_span(s, &comp); let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| { let folder = solids_folder(proj); let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect })); let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None); + fit_span(&mut l, span); if adjustment { l.switches.adjustment = true; l.label = Label::Purple; @@ -186,9 +204,11 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } } let edit = b_p(p, "edit").unwrap_or(false); + let span = new_layer_span(s, &comp); let id = s.edit("New Text Layer", None, |proj, st| { let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text)); let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); + fit_span(&mut l, span); // Named after its text, the name follows the text until renamed; a given name does not. l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty)); if let Some(pr) = l.props.prop_mut("text/sourceText") { @@ -232,8 +252,9 @@ pub(super) fn new_shape(s: &mut Session, p: &Value) -> Result { .and_then(|v| v.as_array()) .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string(); + let span = new_layer_span(s, &comp); let id = s.edit("New Shape Layer", None, |proj, st| { - let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?; + let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos, span)?; let mut next = proj.next_id; let g = crate::cmd::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint); proj.next_id = next; @@ -249,8 +270,10 @@ fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: & let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let name = str_p(p, "name").unwrap_or(name).to_string(); + let span = new_layer_span(s, &comp); let id = s.edit(label, None, |proj, st| { - let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); + let mut l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); + fit_span(&mut l, span); insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index 57b22ba82..99f1cd859 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -8,6 +8,7 @@ use effectcraft_geom::vec2; use effectcraft_keyframe::{Gradient, ShapePath, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup}; +use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -263,7 +264,8 @@ pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Res } /// A new empty shape layer ("Shape Layer n" unless `name`) with its Position at `position` (comp -/// pixels; else the comp centre), above the selected layer and selected. +/// pixels; else the comp centre), above the selected layer and selected. `span` is the +/// [`new_layer_span`](super::layer::new_layer_span) the layer takes, if any. pub(crate) fn new_shape_layer( proj: &mut Project, st: &mut EditorState, @@ -271,11 +273,13 @@ pub(crate) fn new_shape_layer( cid: ItemId, name: Option<&str>, position: Option<[f64; 2]>, + span: Option<(Tick, Tick)>, ) -> Result { let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); let name = name.map(str::to_string).unwrap_or_else(|| format!("Shape Layer {}", count + 1)); let mut l = build::layer(proj, comp, &name, LayerSource::Shape, (comp.width, comp.height), None); place(&mut l, position); + super::layer::fit_span(&mut l, span); insert_layer(proj, st, cid, l) } @@ -346,10 +350,11 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { _ => "Rectangle Tool", }; let name = str_p(p, "name"); + let span = super::layer::new_layer_span(s, &comp); let (lid, group) = s.edit(label, None, |proj, st| { let lid = match target { Some(l) => l, - None => new_shape_layer(proj, st, &comp, cid, name, Some(pos))?, + None => new_shape_layer(proj, st, &comp, cid, name, Some(pos), span)?, }; let mut next = proj.next_id; let g = shape_group(&mut Ids(&mut next), kind, size, &paint).ok_or_else(|| bad(c, "unknown kind"))?; diff --git a/crates/engine/src/cmd/viewer_cmds.rs b/crates/engine/src/cmd/viewer_cmds.rs index 698a46f02..39a5102c9 100644 --- a/crates/engine/src/cmd/viewer_cmds.rs +++ b/crates/engine/src/cmd/viewer_cmds.rs @@ -282,10 +282,11 @@ fn new_path(s: &mut Session, p: &Value) -> Result { path.vertices.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), v| ([lo[0].min(v[0]), lo[1].min(v[1])], [hi[0].max(v[0]), hi[1].max(v[1])])); let bounds = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]]; let name = str_p(p, "name").map(str::to_string); + let span = super::layer::new_layer_span(s, &comp); let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| { let lid = match target { Some(l) => l, - None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None)?, + None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None, span)?, }; let mut next = proj.next_id; let mut ids = Ids(&mut next); diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index c23b2b5b1..d7c50cdce 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -92,6 +92,7 @@ pub fn pages() -> Vec { s("general.showToolTips", "Show Tool Tips", B, true), s("general.viewerWheelScrubbing", "Scrub Composition Frames with Mouse Wheel", B, true), s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true), + s("general.fitNewLayersToWorkArea", "Fit New Layers to Work Area", B, true), s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true), s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true), s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index ca0e72bef..46d654ab0 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -36,6 +36,9 @@ page!(General { viewer_wheel_scrubbing: bool = false, /// Create New Layers at Composition Start Time (off: at the current time). create_layers_at_comp_start: bool = true, + /// Fit New Layers to Work Area: new text, solid, shape, null and adjustment layers span the + /// work area instead of the whole composition (off, as in After Effects). + fit_new_layers_to_work_area: bool = false, switches_affect_nested_comps: bool = true, /// Default Spatial Interpolation to Linear. default_spatial_linear: bool = false, diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index b37c5ca3e..34f7689f6 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -382,6 +382,42 @@ fn new_layers_start_at_the_current_time_when_asked() { assert_eq!(s.active_comp().unwrap().layers[0].start_time.seconds().round(), 2.0); } +/// Settings ▸ General ▸ Fit New Layers to Work Area: new text, solid, shape, null and adjustment +/// layers span the work area, ahead of the start-time setting; footage keeps its own length. +#[test] +fn new_layers_fit_the_work_area_when_asked() { + let mut s = session_with_comp(); + s.execute("comp.workArea", json!({"start": 2, "end": 5})).unwrap(); + let span = |s: &Session| { + let l = &s.active_comp().unwrap().layers[0]; + let r = |t: effectcraft_time::Tick| (t.seconds() * 10.0).round() / 10.0; + (r(l.start_time), r(l.in_point), r(l.out_point)) + }; + s.execute("layer.newSolid", json!({})).unwrap(); + assert_eq!(span(&s), (0.0, 0.0, 10.0), "off by default"); + + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + for (cmd, p) in [ + ("layer.newSolid", json!({})), + ("layer.newAdjustment", json!({})), + ("layer.newNull", json!({})), + ("layer.newText", json!({"text": "a"})), + ("layer.newShape", json!({"kind": "rect"})), + ("shape.newShape", json!({"kind": "ellipse"})), + ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]})), + ] { + s.state.selected_layers.clear(); + s.execute(cmd, p).unwrap(); + assert_eq!(span(&s), (2.0, 2.0, 5.0), "{cmd}"); + } + + let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 3, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.addItem", json!({"item": nested})).unwrap(); + assert_eq!(span(&s), (7.0, 7.0, 10.0), "footage and comps keep their own timing"); +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); diff --git a/docs/preferences.md b/docs/preferences.md index e20084c5f..69abf3e76 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -37,6 +37,7 @@ Commands (CLI, MCP, control channel): - `general.showToolTips`: Show Tool Tips - `general.viewerWheelScrubbing`: Scrub Composition Frames with Mouse Wheel (off by default). On: an unmodified mouse wheel over the Composition viewer steps frames, and the smoothed tail of that notch does not zoom or pan. Ctrl/Cmd+wheel and a trackpad still zoom and pan. - `general.createLayersAtCompStart`: Create Layers at Composition Start Time +- `general.fitNewLayersToWorkArea`: Fit New Layers to Work Area (off by default). On: new text, solid, shape, null and adjustment layers start at the work area start and end at its end, ahead of Create Layers at Composition Start Time. Footage and compositions keep their own length. - `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear - `startup.showHomeOnLaunch`: Show Home Screen When Launching - `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash From abdf4c369ad67e8a8cbd0eb940cfb5e562348eaa Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 08:34:05 +0100 Subject: [PATCH 2/6] Settings: move the playhead onto layers the tools fit to the work area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Fit New Layers to Work Area on and the playhead outside the work area, the Type tool, shape tools and Pen now move the playhead to the work area start so the new layer and its controls show. Layer timing is unchanged; Layer ▸ New commands leave the playhead. --- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/cmd/layer/create.rs | 13 ++++++++++ crates/engine/src/cmd/shape_tool.rs | 3 +++ crates/engine/src/cmd/viewer_cmds.rs | 3 +++ crates/engine/src/tests/settings.rs | 24 ++++++++++++------- crates/ui-egui/tests/it/ui_text_edit.rs | 30 +++++++++++++++++++++++ crates/ui-egui/tests/it/ui_viewer.rs | 32 +++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 89cf45158..de43454f4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -24,7 +24,7 @@ mod transform; use contents::{add_mask, add_shape_item, mask_props, set_text}; use create::{add_item, new_adjustment, new_null, new_shape, new_solid, new_text}; -pub(crate) use create::{color_p, fit_span, index_p, insert_layer, insert_layer_at, new_layer_span, place, position_p}; +pub(crate) use create::{color_p, fit_span, index_p, insert_layer, insert_layer_at, new_layer_span, place, position_p, show_span}; pub(crate) use parent::parent_fixes; use parent::set_parent; use replace::{has_replace_source, replace_source}; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 14d9f1e0f..5147ccc71 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -30,6 +30,16 @@ 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)>) { + if let Some((start, end)) = span + && !(start..end).contains(&s.time()) + { + s.set_time(start); + } +} + /// Insert a new layer above the selection (or at the top) and select it. pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { insert_layer_at(proj, st, cid, layer, None) @@ -233,6 +243,9 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } Ok(id) })?; + if edit { + show_span(s, 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 99f1cd859..d983ce8d0 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -363,6 +363,9 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { st.selected_layers = vec![lid]; Ok((lid, group)) })?; + if target.is_none() { + super::layer::show_span(s, 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 39a5102c9..2840873cd 100644 --- a/crates/engine/src/cmd/viewer_cmds.rs +++ b/crates/engine/src/cmd/viewer_cmds.rs @@ -301,6 +301,9 @@ fn new_path(s: &mut Session, p: &Value) -> Result { st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }]; Ok((lid, group, path_uid)) })?; + if target.is_none() { + super::layer::show_span(s, span); + } Ok(json!({"layer": lid.0, "group": group, "path": path_uid})) } diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 34f7689f6..f8eb8ed82 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -398,20 +398,26 @@ fn new_layers_fit_the_work_area_when_asked() { s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap(); - s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); - for (cmd, p) in [ - ("layer.newSolid", json!({})), - ("layer.newAdjustment", json!({})), - ("layer.newNull", json!({})), - ("layer.newText", json!({"text": "a"})), - ("layer.newShape", json!({"kind": "rect"})), - ("shape.newShape", json!({"kind": "ellipse"})), - ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]})), + // The tools that draw on their new layer (Type, shape tools, Pen) move the playhead into it; + // the menu commands leave it. + for (cmd, p, draws) in [ + ("layer.newSolid", json!({}), false), + ("layer.newAdjustment", json!({}), false), + ("layer.newNull", json!({}), false), + ("layer.newText", json!({"text": "a"}), false), + ("layer.newText", json!({"text": "a", "edit": true}), true), + ("layer.newShape", json!({"kind": "rect"}), false), + ("shape.newShape", json!({"kind": "ellipse"}), true), + ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}), true), ] { + s.set_time(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!(span(&s), (2.0, 2.0, 5.0), "{cmd}"); + assert_eq!(s.time().seconds().round(), if draws { 2.0 } else { 7.0 }, "{cmd} playhead"); } + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 3, "open": false})).unwrap()["comp"].as_u64().unwrap(); s.execute("layer.addItem", json!({"item": nested})).unwrap(); diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 6b68028de..24a43522f 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -169,6 +169,36 @@ fn type_tool_click_type_edit_and_commit() { assert!(h.state().session.state.text_edit.is_none()); } +/// Fit New Layers to Work Area with the playhead past the work area: the Type tool's new layer +/// spans the work area and the playhead moves into it, so the text shows while it is typed and +/// after it is committed. +#[test] +fn type_tool_text_fitted_to_the_work_area_past_the_playhead_shows() { + let mut h = harness(app()); + h.state_mut().show_panel(PanelKind::Composition); + let s = &mut h.state_mut().session; + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 0.5, "end": 1})).unwrap(); + s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.5)); + h.run_steps(3); + h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type; + h.run_steps(1); + let comp = rect(&h, "viewer.comp"); + click(&mut h, comp.center(), Modifiers::NONE); + type_text(&mut h, "Hi"); + assert!(h.state().auto.find("viewer.textEdit").is_some(), "the edit region shows"); + let (lid, text, _, _) = edited(&h); + assert_eq!(text, "Hi"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "committed"); + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap(); + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "the committed text is on screen: {t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(((l.in_point.seconds() * 10.0).round(), (l.out_point.seconds() * 10.0).round()), (5.0, 10.0), "the layer keeps the work area"); + assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hi"); +} + /// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the /// keyboard back, whether the edit began with the Type tool (which stays selected) or by command: /// typed characters change no text and Space plays. The Character panel keeps the edit, and a diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 7934168cd..264f19ad0 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -1145,6 +1145,38 @@ fn shape_pen_draws_a_closed_shape_layer() { assert_eq!(sp.vertices.len(), 4); } +/// Fit New Layers to Work Area with the playhead past the work area: the Pen's new shape layer +/// spans the work area and the playhead moves into it, so the path and its vertex controls show +/// and the closing click lands. +#[test] +fn pen_draws_on_a_layer_fitted_to_the_work_area_past_the_playhead() { + let mut h = harness(); + let s = &mut h.state_mut().session; + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 1, "end": 2})).unwrap(); + s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let n0 = h.state().session.active_comp().unwrap().layers.len(); + for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0]] { + let at = screen(&h, p); + click(&mut h, at); + } + let l = h.state().session.active_comp().unwrap().layers[0].clone(); + let t = h.state().session.time(); + assert!(l.in_point <= t && t < l.out_point, "the playhead is on the new layer: {t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!((l.in_point.seconds().round(), l.out_point.seconds().round()), (1.0, 2.0), "the layer keeps the work area"); + let path = l.props.sub("contents").unwrap().groups().next().unwrap().sub("contents").unwrap().groups().next().unwrap().clone(); + assert!(h.state().auto.find(&format!("viewer.shapePath.{}.vertex.0", path.uid)).is_some(), "the Pen's vertex controls show"); + let at = screen(&h, [200.0, 100.0]); + click(&mut h, at); + let comp = h.state().session.active_comp().unwrap().clone(); + assert_eq!(comp.layers.len(), n0 + 1); + let sp = comp.layers[0].props.find_group(path.uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + assert_eq!(sp.vertices.len(), 3); + assert!(sp.closed, "the click on the first vertex closes the path"); +} + #[test] fn motion_path_key_drag_edits_that_key() { let mut h = harness(); From 34086899ad5da4e5d5478f9d1e54cb56c7d4828c Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 08:47:25 +0100 Subject: [PATCH 3/6] Settings: fit tools move the target comp's playhead, not the active one show_span now checks and moves the playhead of the comp the layer was made in, through a new Session::set_time_of that set_time uses for the active comp, so time sync and the text editor boundary stay the same. --- crates/engine/src/cmd/layer/create.rs | 10 +++++----- crates/engine/src/cmd/shape_tool.rs | 2 +- crates/engine/src/cmd/viewer_cmds.rs | 2 +- crates/engine/src/session/comps.rs | 23 ++++++++++++++--------- crates/engine/src/tests/settings.rs | 25 +++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 16 deletions(-) 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(); From 1991ad5d56d590a45e9d8ab0e8978908294691bc Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 09:15:58 +0100 Subject: [PATCH 4/6] Settings: fit tools move the playhead to the first frame inside the span The work area need not sit on frames (a typed start, a frame-rate change), so rounding its start to the nearest frame could land before the new layer's in-point. show_span now rounds up to the first frame in the span and moves nothing when the span holds no frame. --- crates/engine/src/cmd/layer/create.rs | 11 +++++++++-- crates/engine/src/tests/settings.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 0b5116e95..c46222e7e 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -31,12 +31,19 @@ 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 `comp`'s playhead to the span's start so the new layer shows. The layer's timing is kept. +/// move `comp`'s playhead to the first frame in the span so the new layer shows. The layer's timing +/// is kept. The work area need not sit on frames (a typed start, a frame-rate change), so the +/// frame is rounded up from the start, not to the nearest; a span holding no frame moves nothing. pub(crate) fn show_span(s: &mut Session, comp: ItemId, span: Option<(Tick, Tick)>) { + let Some(fr) = s.project.comp(comp).map(|c| c.frame_rate) else { return }; if let Some((start, end)) = span && !(start..end).contains(&s.time_of(comp)) { - s.set_time_of(comp, start); + let first = fr.snap(start); + let first = if first < start { fr.tick_of(fr.frame_at(start).saturating_add(1)) } else { first }; + if first < end { + s.set_time_of(comp, first); + } } } diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 556135fff..29792efc0 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -449,6 +449,27 @@ fn fitted_layers_in_another_comp_move_that_comps_playhead() { } } +/// A work area off the frame grid (set at 25 fps, then the comp went to 30 fps): the playhead +/// goes to the first frame inside it, not the nearest frame before its start. +#[test] +fn fitted_layers_off_the_frame_grid_move_the_playhead_inside_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Grid", "duration": 2, "frameRate": 25})).unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 0.04, "end": 0.08})).unwrap(); + s.execute("comp.settings", json!({"frameRate": 30})).unwrap(); + for (cmd, p) in [("layer.newText", json!({"text": "a", "edit": true})), ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}))] { + s.set_time(effectcraft_time::Tick::from_seconds_f64(1.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + let l = &s.active_comp().unwrap().layers[0]; + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "{cmd}: {t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0 / 30.0), "{cmd}: the first frame inside"); + } +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); From c344ff8c237819115c0806b750987b47873175bc Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 09:55:54 +0100 Subject: [PATCH 5/6] Time: keep a composition's final partial frame reachable A frame-rate change can leave the duration off the frame grid. The current-time clamp used duration minus one frame, which put the last reachable frame before a final partial frame, so a layer fitted to a work area inside it never got the playhead. Clamp to the last frame that starts before the end instead; on-grid durations are unchanged. --- crates/engine/src/session/comps.rs | 7 +++++-- crates/engine/src/tests/settings.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index 8c2e87647..f8aba8f5a 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -1,8 +1,11 @@ use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands}; -/// `t` as a current time of `comp`: a frame within its duration. +/// `t` as a current time of `comp`: a frame within its duration. The last frame is the last one +/// that starts before the end, so a final partial frame (a duration off the frame grid after a +/// frame-rate change) stays reachable; on the grid it is `duration - frame_duration`. fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { - comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO)) + let last = comp.frame_rate.snap(comp.duration - Tick(1)).max(Tick::ZERO); + comp.frame_rate.snap_nearest(t.max(Tick::ZERO)).min(last) } impl Session { diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 29792efc0..8caafb3e9 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -470,6 +470,26 @@ fn fitted_layers_off_the_frame_grid_move_the_playhead_inside_them() { } } +/// A duration off the frame grid (2.01 s set at 100 fps, then 24 fps) keeps its final partial +/// frame reachable: a layer fitted to a work area inside it gets the playhead on that frame. +#[test] +fn fitted_layers_in_the_final_partial_frame_get_the_playhead() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Tail", "duration": 2.01, "frameRate": 100})).unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 2.0, "end": 2.01})).unwrap(); + s.execute("comp.settings", json!({"frameRate": 24})).unwrap(); + s.set_time(effectcraft_time::Tick::ZERO); + s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap(); + let l = &s.active_comp().unwrap().layers[0]; + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "{t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0), "frame 48 of 24 fps"); + // The playhead can't go past that last frame. + s.set_time(effectcraft_time::Tick::from_seconds_f64(5.0)); + assert_eq!(s.time(), effectcraft_time::Tick::from_seconds_f64(2.0)); +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); From 5f1c5ad4048144c7305b1d6254bc2a9ba3b045d2 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 10:19:45 +0100 Subject: [PATCH 6/6] Time: synced comp times reach the final partial frame too Synchronize Time of All Related Items clamped each related comp's time with its own duration-minus-one-frame rule, so a nested comp whose duration is off the frame grid could not reach its final partial frame. Use comp_frame_at, as direct moves and restored times do. --- crates/engine/src/session/comps.rs | 6 +++--- crates/engine/src/tests/settings.rs | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index f8aba8f5a..6ea79f0d9 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -147,8 +147,8 @@ impl Session { /// Sets the time of every comp related to `comp` through precomp layers: down into the comps /// it nests (through each layer's timing) and up into the comps nesting it. A seen-set keeps - /// cycles and diamonds finite; each found time is clamped to its comp's last frame, snapped to - /// the nearest frame and stored in `state.times`. The caller owns the text-editor boundary. + /// cycles and diamonds finite; each found time goes through [`comp_frame_at`] (as a direct move + /// does) and is stored in `state.times`. The caller owns the text-editor boundary. fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) { let mut seen = std::collections::BTreeSet::from([comp]); let mut todo = vec![(comp, t)]; @@ -178,7 +178,7 @@ impl Session { } for (cid, ct) in found { if let Some(c) = self.project.comp(cid) { - let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO))); + let ct = comp_frame_at(c, ct); self.state.times.insert(cid, ct); } } diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 8caafb3e9..0f8d6ffa2 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -490,6 +490,19 @@ fn fitted_layers_in_the_final_partial_frame_get_the_playhead() { assert_eq!(s.time(), effectcraft_time::Tick::from_seconds_f64(2.0)); } +/// Synchronize Time of All Related Items reaches a nested comp's final partial frame as a direct +/// move does: the inner comp is 2.01 s (set at 100 fps, then 24 fps), the outer sits at 2.0 s. +#[test] +fn synced_time_reaches_a_nested_final_partial_frame() { + let mut s = Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 2.01, "frameRate": 100, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.settings", json!({"comp": inner, "frameRate": 24})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "duration": 4, "frameRate": 24})).unwrap(); + s.execute("layer.addItem", json!({"item": inner, "time": 0})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(2.0)); + assert_eq!(s.time_of(effectcraft_project::ItemId(inner)), effectcraft_time::Tick::from_seconds_f64(2.0)); +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp();