From 34f62efbcaef1b915e8b0ff81c9b6847482bd673 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Wed, 11 Nov 2026 08:11:39 +0100 Subject: [PATCH] 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