Settings: Fit New Layers to Work Area

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.
This commit is contained in:
nazar-gavrylyk
2026-11-11 08:11:39 +01:00
parent 4f601cbe1a
commit 34f62efbca
8 changed files with 76 additions and 6 deletions
+1 -1
View File
@@ -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};
+25 -2
View File
@@ -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<LayerId> {
insert_layer_at(proj, st, cid, layer, None)
@@ -89,10 +105,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
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<Value> {
}
}
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<Value> {
.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}))
+7 -2
View File
@@ -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<LayerId> {
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<Value> {
_ => "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"))?;
+2 -1
View File
@@ -282,10 +282,11 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
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);
@@ -92,6 +92,7 @@ pub fn pages() -> Vec<Page> {
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),
@@ -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,
+36
View File
@@ -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();
+1
View File
@@ -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