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