From 5586b40cf06303392dd9ecdecb569a56201b3461 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 11:43:31 +0000 Subject: [PATCH 01/60] #1190: clear existing STL Clippy warning --- crates/model/src/stl.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858ad..34e9a7f06 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` From ab692dce702abaf1a0cc4d31fc1f0ae3044046b4 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 11:43:31 +0000 Subject: [PATCH 02/60] #1190: enable drag toggling for built-in checkboxes --- crates/ui-egui/src/header.rs | 4 +- crates/ui-egui/src/panels/anim_tools.rs | 2 +- crates/ui-egui/src/panels/comp_settings.rs | 6 +- crates/ui-egui/src/panels/dialogs.rs | 8 +- crates/ui-egui/src/panels/dialogs_3d.rs | 6 +- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/essential.rs | 2 +- crates/ui-egui/src/panels/forms.rs | 16 +- crates/ui-egui/src/panels/key_dialogs.rs | 2 +- .../ui-egui/src/panels/layer_styles_dialog.rs | 4 +- crates/ui-egui/src/panels/markers_ui.rs | 4 +- crates/ui-egui/src/panels/misc.rs | 2 +- crates/ui-egui/src/panels/paint_panels.rs | 6 +- crates/ui-egui/src/panels/panel_kit.rs | 2 +- crates/ui-egui/src/panels/path_vr_panels.rs | 2 +- crates/ui-egui/src/panels/precomp.rs | 4 +- crates/ui-egui/src/panels/project.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/render_queue.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 2 +- crates/ui-egui/src/panels/text_panels.rs | 22 +- crates/ui-egui/src/panels/timeline.rs | 6 +- crates/ui-egui/src/panels/tracker.rs | 14 +- crates/ui-egui/src/widgets.rs | 191 +++++++++++++++++- crates/ui-egui/tests/ui_checkbox_drag.rs | 54 +++++ 25 files changed, 306 insertions(+), 63 deletions(-) create mode 100644 crates/ui-egui/tests/ui_checkbox_drag.rs diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index fb381cfd3..571229007 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -174,7 +174,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; - if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() { + if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).changed() { let _ = app.session.execute("view.snapping", serde_json::json!({})); app.ui.snapping = app.session.state.snapping; } @@ -591,7 +591,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter p.text(pos2(x, cy), Align2::LEFT_CENTER, tr("Mesh:"), Tokens::ui(12.0), t.text_dim); x += 40.0; let show = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); - if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).clicked() { + if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).changed() { let _ = app.session.execute("puppet.mesh", json!({"showMesh": !o.show_mesh})); } app.auto.add("header.puppet.showMesh", show, "Show mesh"); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index 15a4f4f89..8d4066a7f 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -41,7 +41,7 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(at, vec2(16.0, 16.0)); - let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).clicked(); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).changed(); ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); app.auto.add(auto, r, text); if clicked { !on } else { on } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..14b1e7a9c 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -321,7 +321,7 @@ pub fn show( let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); - let r = ui.checkbox(&mut d.lock_aspect, lbl); + let r = crate::widgets::labelled_checkbox(ui, &mut d.lock_aspect, lbl); auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio"); if d.lock_aspect && ow != d.width && ow > 0 { d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32; @@ -416,11 +416,11 @@ pub fn show( }); ui.end_row(); ui.label(""); - let r = ui.checkbox(&mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue")); auto.add("dialog.comp.preserveFrameRate", r.rect, "Preserve frame rate when nested or in render queue"); ui.end_row(); ui.label(""); - let r = ui.checkbox(&mut d.preserve_resolution, tr("Preserve resolution when nested")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_resolution, tr("Preserve resolution when nested")); auto.add("dialog.comp.preserveResolution", r.rect, "Preserve resolution when nested"); ui.end_row(); ui.label(egui::RichText::new(tr("Motion Blur")).strong()); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..b458701fc 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -480,7 +480,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.width", r.rect, "Width"); - let r = ui.checkbox(&mut lock, tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))])); + let r = crate::widgets::labelled_checkbox( + ui, + &mut lock, + tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))]), + ); auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio"); }); ui.end_row(); @@ -536,7 +540,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if editing.is_some() { ui.add_space(8.0); let r = ui - .add_enabled(shared, egui::Checkbox::new(&mut affect_all, tr("Affect all layers that use this solid"))) + .add_enabled(shared, crate::widgets::DragCheckbox::new(&mut affect_all, tr("Affect all layers that use this solid"))) .on_disabled_hover_text(tr("Only this layer uses this solid.")); auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid"); if shared && !affect_all { diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index a1a67bc60..84cf8d009 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -201,12 +201,12 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Depth of Field")); - ui.checkbox(&mut d.dof, tr("Enable Depth of Field")); + crate::widgets::labelled_checkbox(ui, &mut d.dof, tr("Enable Depth of Field")); ui.end_row(); ui.label(tr("Focus Distance")); ui.horizontal(|ui| { ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px"))); - ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom")); + crate::widgets::labelled_checkbox(ui, &mut d.lock_to_zoom, tr("Lock to Zoom")); }); ui.end_row(); ui.label(tr("Aperture")); @@ -301,7 +301,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); } ui.label(tr("Shadows")); - ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + crate::widgets::labelled_checkbox(ui, &mut d.casts_shadows, tr("Casts Shadows")); ui.end_row(); ui.label(tr("Shadow Darkness")); ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..87bc006c3 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -404,7 +404,7 @@ fn prop_row( } Value::Bool(b) => { let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).clicked() { + if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).changed() { set(actions, json!(!b)); } p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 2e99c7d39..3dd9be36a 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -353,7 +353,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Checkbox => { let mut on = v.as_bool(); - let r = ui.checkbox(&mut on, ""); + let r = crate::widgets::labelled_checkbox(ui, &mut on, ""); if r.changed() { out = Some(json!(on)); } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 275e54b75..b37351336 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -814,7 +814,7 @@ fn form_grid(ui: &mut egui::Ui, f: &mut Form, regs: &mut Vec<(String, egui::Rect .inner .rect } - FieldKind::Bool(b) => ui.checkbox(b, "").rect, + FieldKind::Bool(b) => crate::widgets::labelled_checkbox(ui, b, "").rect, FieldKind::Choice { options, sel } => { let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default(); // The column keeps its width: a long choice (a GPU adapter's name) ends in an @@ -944,13 +944,13 @@ pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Toke let mut close = false; super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| { let v = &mut app.ui.viewer; - ui.checkbox(&mut v.show_layer_controls, tr("Layer controls")); - ui.checkbox(&mut v.show_masks, tr("Masks")); - ui.checkbox(&mut v.rulers, tr("Rulers")); - ui.checkbox(&mut v.guides, tr("Guides")); - ui.checkbox(&mut v.grid, tr("Grid")); - ui.checkbox(&mut v.safe_margins, tr("Title/action safe")); - ui.checkbox(&mut v.transparency_grid, tr("Transparency grid")); + crate::widgets::labelled_checkbox(ui, &mut v.show_layer_controls, tr("Layer controls")); + crate::widgets::labelled_checkbox(ui, &mut v.show_masks, tr("Masks")); + crate::widgets::labelled_checkbox(ui, &mut v.rulers, tr("Rulers")); + crate::widgets::labelled_checkbox(ui, &mut v.guides, tr("Guides")); + crate::widgets::labelled_checkbox(ui, &mut v.grid, tr("Grid")); + crate::widgets::labelled_checkbox(ui, &mut v.safe_margins, tr("Title/action safe")); + crate::widgets::labelled_checkbox(ui, &mut v.transparency_grid, tr("Transparency grid")); ui.add_space(12.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)).clicked() { diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 73f76aaaa..56fd5185a 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -160,7 +160,7 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.add_space(8.0); } - let r = ui.checkbox(&mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)")); app.auto.add("dialog.velocity.continuous", r.rect, "Continuous"); if d.continuous { d.out_speed = d.in_speed.clone(); diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index c0b03f77e..b3b78fb57 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -86,7 +86,7 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { match (v, &p.ui) { (KV::Bool(b), _) => { let mut on = *b; - ui.checkbox(&mut on, "").changed().then(|| json!(on)) + crate::widgets::labelled_checkbox(ui, &mut on, "").changed().then(|| json!(on)) } (KV::Enum(i), ParamUi::Popup { options }) => { let mut sel = *i as usize; @@ -170,7 +170,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if id != BLENDING { let g = styles.as_ref().and_then(|s| s.sub(id)); let mut on = g.is_some_and(|g| g.enabled); - if ui.checkbox(&mut on, "").changed() { + if crate::widgets::labelled_checkbox(ui, &mut on, "").changed() { if g.is_none() { actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id}))); } else { diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 2fff7e1c8..102309765 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -155,7 +155,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(8.0); - let r = ui.checkbox(&mut d.cue, tr("Cue Point")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.cue, tr("Cue Point")); app.auto.add("dialog.marker.cue", r.rect, "Cue Point"); ui.add_enabled_ui(d.cue, |ui| { ui.horizontal(|ui| { @@ -191,7 +191,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.add_space(8.0); ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.protected, tr("Protected Region")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.protected, tr("Protected Region")); app.auto.add("dialog.marker.protected", r.rect, "Protected Region"); ui.add_space(20.0); ui.label(tr("Label:")); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index cd0ab6897..5a1b77c07 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -240,7 +240,7 @@ fn fmt_rate(r: f64) -> String { fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0)); - let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).clicked(); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).changed(); p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); app.auto.add(&format!("preview.{key}"), r, text); clicked diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs index 675bc1e16..3c9332aea 100644 --- a/crates/ui-egui/src/panels/paint_panels.rs +++ b/crates/ui-egui/src/panels/paint_panels.rs @@ -210,7 +210,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .enumerate() { let r = Rect::from_min_size(pos2(x0 + i as f32 * (w / 2.0), y), vec2(18.0, 20.0)); - if widgets::checkbox(ui, r, on, &t, egui::Id::new(key)).clicked() { + if widgets::checkbox(ui, r, on, &t, egui::Id::new(key)).changed() { set(app, json!({k: !on})); } p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); @@ -237,7 +237,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 26.0; let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0)); - if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::new("paint.showOverlay")).clicked() { + if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::new("paint.showOverlay")).changed() { set(app, json!({"showOverlay": !o.show_overlay})); } app.auto.add("paint.showOverlay", r, "Clone Source Overlay"); @@ -246,7 +246,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set(app, json!({"overlayOpacity": nv})); } let dr = Rect::from_min_size(pos2(x0 + w - 18.0, y), vec2(18.0, 20.0)); - if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::new("paint.overlayDifference")).clicked() { + if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::new("paint.overlayDifference")).changed() { set(app, json!({"overlayDifference": !o.overlay_difference})); } app.auto.add("paint.overlayDifference", dr, "Difference mode overlay"); diff --git a/crates/ui-egui/src/panels/panel_kit.rs b/crates/ui-egui/src/panels/panel_kit.rs index cbf28c8ac..06dc8b4db 100644 --- a/crates/ui-egui/src/panels/panel_kit.rs +++ b/crates/ui-egui/src/panels/panel_kit.rs @@ -23,7 +23,7 @@ pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(at, vec2(16.0, 16.0)); - let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).clicked(); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).changed(); ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0)); app.auto.add(auto, full, text); diff --git a/crates/ui-egui/src/panels/path_vr_panels.rs b/crates/ui-egui/src/panels/path_vr_panels.rs index 9dba0dd59..b9d747a3b 100644 --- a/crates/ui-egui/src/panels/path_vr_panels.rs +++ b/crates/ui-egui/src/panels/path_vr_panels.rs @@ -54,7 +54,7 @@ pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) })); } } - let r = ui.checkbox(&mut looping, tr("Loop (Trace Path)")); + let r = crate::widgets::labelled_checkbox(&mut ui, &mut looping, tr("Loop (Trace Path)")); app.auto.add("createNulls.loop", r.rect, "Loop"); ui.data_mut(|d| d.insert_temp(loop_id, looping)); run(app, &ctx, acts); diff --git a/crates/ui-egui/src/panels/precomp.rs b/crates/ui-egui/src/panels/precomp.rs index 8a40c56e9..f6d3068b1 100644 --- a/crates/ui-egui/src/panels/precomp.rs +++ b/crates/ui-egui/src/panels/precomp.rs @@ -141,11 +141,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ); ui.add_space(4.0); ui.add_enabled_ui(!d.leave, |ui| { - let r = ui.checkbox(&mut d.adjust, tr("Adjust composition duration to the time span of the selected layers")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.adjust, tr("Adjust composition duration to the time span of the selected layers")); app.auto.add("dialog.precompose.adjust", r.rect, "Adjust composition duration"); }); ui.add_space(8.0); - let r = ui.checkbox(&mut d.open, tr("Open New Composition")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.open, tr("Open New Composition")); app.auto.add("dialog.precompose.open", r.rect, "Open New Composition"); ui.add_space(14.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8c..62938e635 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -1094,7 +1094,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); - if ui.checkbox(&mut on, label).changed() { + if crate::widgets::labelled_checkbox(ui, &mut on, label).changed() { set_column(&mut app.ui.project_columns, key, on); } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..9939b56fa 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -402,7 +402,7 @@ fn text_section( for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { let ry = y + i as f32 * 24.0; let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0)); - if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).clicked() { + if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).changed() { let k = if key == "fill" { "applyFill" } else { "applyStroke" }; set(actions, json!({k: !on})); } diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index e07efe69e..539fb2af1 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -463,7 +463,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Notify (sound + notification when the queue finishes). let notify = app.session.project.render_prefs.notify; let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0)); - if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() { + if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).changed() { actions.push(("renderQueue.setNotify", json!({"notify": !notify}))); } p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); @@ -589,7 +589,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&aid("twirl"), tw, "Twirl"); let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0)); - if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering { + if ui.add_enabled_ui(!rendering, |ui| widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id)))).inner.changed() && !rendering { actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render}))); } app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" }); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b2d2ca435..77eb7e7c0 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -106,7 +106,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value match kind { Kind::Bool => { let mut b = v.as_bool().unwrap_or(false); - let r = ui.checkbox(&mut b, tr(label)); + let r = crate::widgets::labelled_checkbox(ui, &mut b, tr(label)); if r.changed() { acts.push(Act::Set(key.into(), json!(b))); } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ae2294f1f..fd5e4e17b 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -188,7 +188,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { let fx = x0 + i as f32 * (w / 2.0); let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0)); - if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key))).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key)))).inner.changed() && enabled { actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on})); } app.auto.add(&format!("character.{key}.enabled"), cr, label); @@ -240,7 +240,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += 32.0; // Ligatures. let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0)); - if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures")).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures"))).inner.changed() && enabled { actions.push(json!({"ligatures": !doc.ligatures})); } app.auto.add("character.ligatures", lr, "Ligatures"); @@ -264,7 +264,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .enumerate() { let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0)); - if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key))).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key)))).inner.changed() && enabled { actions.push(json!({key: !on})); } app.auto.add(&format!("character.{key}"), cr, label); @@ -544,7 +544,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 30.0; let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0)); - if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging")).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging"))).inner.changed() && enabled { actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation})); } app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation"); @@ -629,17 +629,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: ui.set_width(rect.width().max(180.0)); // Filter row: a checkbox that keeps only the variable fonts. let filter_h = 22.0; - let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, "")); - let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h)); - let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max); - let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check")); - ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text); + let row = ui.add_sized([ui.available_width(), filter_h], widgets::DragCheckbox::new(&mut only_variable, tr("Variable fonts only"))); app.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only"); - let toggled = row.clicked() || boxed.clicked(); + let toggled = row.changed(); if toggled { - only_variable = !only_variable; ui.data_mut(|d| d.insert_temp(variable_id, only_variable)); - // Typing goes on into the search field. + } + // The native checkbox can take focus on release after changing on press. + // Typing goes on into the search field after either event. + if toggled || row.clicked() { ui.memory_mut(|m| m.request_focus(search_id)); } let query = state.query.trim().to_lowercase(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..79bfe7174 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2146,7 +2146,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.setBlendMode".into(), json!({"layers": [layer.id.0], "mode": opts[i]}))); } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); - if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() { + if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).changed() { actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": "preserveTransparency"}))); } // Track matte: layer picker + kind. @@ -2476,7 +2476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]}))); } let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, ir, inverted, &t, egui::Id::new(("minv", uid))).clicked() { + if widgets::checkbox(ui, ir, inverted, &t, egui::Id::new(("minv", uid))).changed() { actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted}))); } lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim); @@ -4098,7 +4098,7 @@ fn value_editor( } Value::Bool(b) => { let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, r, *b, &t, egui::Id::new(("cb", uid))).clicked() { + if widgets::checkbox(ui, r, *b, &t, egui::Id::new(("cb", uid))).changed() { set(actions, json!(!b)); } app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index bd54625b8..aa537ff8c 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -246,10 +246,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ("scale", "Scale", settings.as_ref().is_some_and(|s| s.scale)), ] { let cr = Rect::from_min_size(pos2(xx, y + 2.0), vec2(16.0, 16.0)); - let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::new(("tracker-cb", key))); + let resp = ui.add_enabled_ui(transformish, |ui| widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::new(("tracker-cb", key)))).inner; app.auto.add(&format!("tracker.{key}"), cr, text); p.text(pos2(cr.max.x + 4.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), if transformish { t.text } else { t.text_faint }); - if resp.clicked() && transformish { + if resp.changed() && transformish { run(app, &ctx, "track.setType", with(json!({ key: !on }))); } xx += 84.0; @@ -506,20 +506,20 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) ui.add_space(8.0); ui.label(egui::RichText::new(crate::i18n::tr("Process Before Match")).font(Tokens::medium(12.5))); ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.blur_on, crate::i18n::tr("Blur")); app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur"); let r = ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels"))); app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels"); - let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.enhance, crate::i18n::tr("Enhance")); app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance"); }); ui.add_space(8.0); - let r = ui.checkbox(&mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning")); app.auto.add("dialog.trackOptions.subpixel", r.rect, "Subpixel Positioning"); - let r = ui.checkbox(&mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame")); app.auto.add("dialog.trackOptions.adaptEveryFrame", r.rect, "Adapt Feature On Every Frame"); - let r = ui.checkbox(&mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale")); app.auto.add("dialog.trackOptions.trackShape", r.rect, "Follow Feature Rotation and Scale"); ui.add_space(8.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d0..1ab352730 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -382,9 +382,96 @@ pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Token resp } -/// Checkbox in the AE look (small square). +/// A primary-button gesture shared by custom and labelled checkboxes in this context. +#[derive(Clone, Default)] +struct CheckboxDrag { + press_frame: u64, + target: Option, + visited: std::collections::HashSet, +} + +/// Returns the requested value. Mixed checkboxes (`None`) and disabled controls are +/// skipped. Each checkbox is visited once, so crossing it again cannot toggle it back. +fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option { + let (pressed, down, released) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released())); + let frame = ui.ctx().cumulative_frame_nr(); + // clicked() reads Context input; do not call it while Context data is locked. + let clicked = response.clicked(); + ui.data_mut(|data| { + let drag = data.get_temp_mut_or_default::(egui::Id::new("checkbox-drag")); + if (!down && !released) || (pressed && drag.press_frame != frame) { + *drag = CheckboxDrag { press_frame: frame, ..Default::default() }; + } + let on = value?; + if !response.enabled() { + return None; + } + if pressed && drag.target.is_none() && response.is_pointer_button_down_on() && response.contains_pointer() { + drag.target = Some(!on); + } + if down + && response.contains_pointer() + && let Some(target) = drag.target + { + if drag.visited.insert(response.id) && on != target { + return Some(target); + } + return None; + } + // The source already changed on press. Suppress its ordinary release click. + if released && drag.target.is_some() { + return None; + } + clicked.then_some(!on) + }) +} + +/// A labelled checkbox with the same drag behavior as the custom panel checkbox. +/// Use through `ui.add`, `ui.put`, or `ui.add_enabled` like `egui::Checkbox`. +pub struct DragCheckbox<'a> { + value: &'a mut bool, + text: egui::WidgetText, +} + +impl<'a> DragCheckbox<'a> { + pub fn new(value: &'a mut bool, text: impl Into) -> Self { + Self { value, text: text.into() } + } +} + +impl egui::Widget for DragCheckbox<'_> { + fn ui(self, ui: &mut Ui) -> Response { + let before = *self.value; + let mut response = ui.add(egui::Checkbox::new(self.value, self.text)); + // egui changes on release; our shared gesture changes on press and entry. + *self.value = before; + response.flags.remove(egui::response::Flags::CHANGED); + if response.clicked() { + ui.ctx().request_repaint(); + } + if let Some(value) = drag_toggle(ui, &response, Some(before)) { + *self.value = value; + response.mark_changed(); + ui.ctx().request_repaint(); + } + response + } +} + +/// A labelled checkbox that allocates its own rectangle. +pub fn labelled_checkbox(ui: &mut Ui, value: &mut bool, text: impl Into) -> Response { + ui.add(DragCheckbox::new(value, text)) +} + +/// Checkbox in the AE look (small square). `changed()` requests a toggle, including +/// keyboard activation and entry during a drag begun on another checkbox. pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) -> Response { - let resp = ui.interact(rect, id, Sense::click()); + let mut resp = ui.interact(rect, id, Sense::click()); + let next = drag_toggle(ui, &resp, Some(on)); + if next.is_some() { + resp.mark_changed(); + } + let on = next.unwrap_or(on); let b = Rect::from_center_size(rect.center(), vec2(12.0, 12.0)); ui.painter().rect_filled(b, 2.0, if on { t.accent } else { t.field_bg }); ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, if on { t.accent } else { t.field_border }), StrokeKind::Inside); @@ -666,6 +753,106 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { mod tests { use super::*; + fn checkbox_frame(ctx: &egui::Context, values: &mut [Option; 5], events: Vec) { + let t = Tokens::for_kind(Default::default()); + let input = egui::RawInput { events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + for (index, value) in values.iter_mut().enumerate() { + let rect = Rect::from_min_size(pos2(20.0, 20.0 + index as f32 * 30.0), vec2(120.0, 20.0)); + ui.add_enabled_ui(index != 3, |ui| { + if index == 4 { + if let Some(on) = value { + ui.put(rect, DragCheckbox::new(on, crate::i18n::tr("Grid"))); + } + } else if let Some(on) = value { + if checkbox(ui, rect, *on, &t, egui::Id::new(index)).changed() { + *on = !*on; + } + } else { + let response = ui.interact(rect, egui::Id::new(index), Sense::click()); + assert_eq!(drag_toggle(ui, &response, None), None); + } + }); + } + }); + output.textures_delta.clear(); + } + + fn checkbox_pointer(y: f32, pressed: Option) -> Vec { + let pos = pos2(28.0, y); + let mut events = vec![egui::Event::PointerMoved(pos)]; + if let Some(pressed) = pressed { + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + events + } + + #[test] + fn checkbox_drag_sets_one_state_skips_mixed_and_disabled_and_ends_on_release() { + let ctx = egui::Context::default(); + let mut values = [Some(false), Some(true), None, Some(false), Some(false)]; + checkbox_frame(&ctx, &mut values, vec![]); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(true))); + assert_eq!(values[0], Some(true)); + // An already-matching neighbour must not invert, even after revisiting. + for y in [58.0, 88.0, 118.0, 148.0, 58.0, 28.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None)); + } + assert_eq!(values, [Some(true), Some(true), None, Some(false), Some(true)]); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values[0], Some(true), "release must not toggle the source again"); + // Begin on the labelled wrapper and drag back into custom checkboxes. + checkbox_frame(&ctx, &mut values, checkbox_pointer(148.0, Some(true))); + assert_eq!(values[4], Some(false)); + for y in [58.0, 28.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None)); + } + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values, [Some(false), Some(false), None, Some(false), Some(false)]); + checkbox_frame(&ctx, &mut values, checkbox_pointer(58.0, None)); + assert_eq!(values[1], Some(false), "hover after release must not paint"); + } + + #[test] + fn checkbox_click_and_keyboard_toggle_once_and_drag_must_start_on_a_checkbox() { + let ctx = egui::Context::default(); + let mut values = [Some(false), Some(false), None, Some(false), Some(false)]; + checkbox_frame(&ctx, &mut values, vec![]); + // Pressing outside the checkboxes must not start a gesture on entry. + checkbox_frame(&ctx, &mut values, checkbox_pointer(200.0, Some(true))); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None)); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values[0], Some(false)); + for source_y in [88.0, 118.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(source_y, Some(true))); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None)); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values[0], Some(false), "mixed and disabled sources cannot begin a drag"); + } + for y in [28.0, 148.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(true))); + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(false))); + } + assert_eq!(values[0], Some(true)); + assert_eq!(values[4], Some(true)); + ctx.memory_mut(|m| m.request_focus(egui::Id::new(0usize))); + checkbox_frame( + &ctx, + &mut values, + vec![egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + assert_eq!(values[0], Some(false), "keyboard activation still works"); + // Fast clicks may deliver press and release together in one input frame. + // This also exercises clicked() without an active drag (outside the data lock). + for (index, y) in [(0, 28.0), (4, 148.0)] { + let before = values[index]; + let mut events = checkbox_pointer(y, Some(true)); + events.extend(checkbox_pointer(y, Some(false))); + checkbox_frame(&ctx, &mut values, events); + assert_eq!(values[index], before.map(|on| !on)); + } + } + /// #488: an inline editor selects its whole text only as it opens. Should it be without the /// keyboard for a frame later (an input method, another handler), it takes the keyboard back /// with the caret where the user put it instead of selecting everything again, which made diff --git a/crates/ui-egui/tests/ui_checkbox_drag.rs b/crates/ui-egui/tests/ui_checkbox_drag.rs new file mode 100644 index 000000000..ba65e54dc --- /dev/null +++ b/crates/ui-egui/tests/ui_checkbox_drag.rs @@ -0,0 +1,54 @@ +//! #1190: the shared drag gesture also reaches real dialog controls. +use effectcraft_engine::Session; +use effectcraft_ui_egui::{EffectcraftApp, menus}; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable as _}; +use serde_json::json; + +fn pointer(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, pressed: Option) { + h.event(Event::PointerMoved(at)); + h.step(); + if let Some(pressed) = pressed { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +#[test] +fn view_options_drag_checks_and_unchecks_neighbours() { + let mut session = Session::default(); + session.prefs.disk.disk_cache_enabled = false; + session.execute("comp.new", json!({"width":32,"height":32,"duration":1})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.state_mut().ui.viewer.rulers = false; + h.state_mut().ui.viewer.guides = true; + h.state_mut().ui.viewer.grid = false; + h.run_steps(3); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "view.options", json!({})).unwrap(); + h.run_steps(3); + let rulers = h.get_by_label("Rulers").rect().center(); + let guides = h.get_by_label("Guides").rect().center(); + let grid = h.get_by_label("Grid").rect().center(); + pointer(&mut h, rulers, Some(true)); + assert!(h.state().ui.viewer.rulers); + pointer(&mut h, guides, None); + assert!(h.state().ui.viewer.guides, "an already-checked neighbour stays checked"); + pointer(&mut h, grid, None); + assert!(h.state().ui.viewer.grid); + pointer(&mut h, rulers, None); + pointer(&mut h, rulers, Some(false)); + assert!(h.state().ui.viewer.rulers, "revisiting and releasing does not undo the change"); + if let Ok(path) = std::env::var("CHECKBOX_REVIEW_PNG") { + h.render().unwrap().save(path).unwrap(); + } + pointer(&mut h, grid, Some(true)); + pointer(&mut h, guides, None); + pointer(&mut h, rulers, None); + pointer(&mut h, rulers, Some(false)); + let viewer = &h.state().ui.viewer; + assert!(!viewer.rulers && !viewer.guides && !viewer.grid); + pointer(&mut h, guides, None); + assert!(!h.state().ui.viewer.guides, "hover after release does not change a checkbox"); +} From 32296d6f653f3efbaf99df06f17f01495e61be07 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:28:22 +0000 Subject: [PATCH 03/60] #1284: Cmd+W closes the Render Queue while it has focus With the Render Queue active, Cmd+W ran comp.close on the active composition, so the composition next to the queue closed and the queue stayed open. Close now follows panel focus, as Clear does: the queue closes and the composition stays open. Covered by menus::tests::cmd_w_in_the_render_queue_closes_the_queue_not_the_comp. Claude-Session: https://claude.ai/code/session_0136kZtXrYo5VUWEFxWduwUD --- crates/ui-egui/src/menus.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b4977..eb89e6ab0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -291,6 +291,12 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V _ => {} } } + // Close follows panel focus too: with the Render Queue active, Cmd+W closes that panel and + // leaves the active composition open (it used to close the composition next to it). + if id == "file.close" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::RenderQueue { + app.close_panel(PanelKind::RenderQueue); + return Ok(Value::Null); + } // Clear follows panel focus, including the Edit menu and keyboard shortcut. if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project { // Through `invoke`, so deleting items that compositions use still asks first (M3.16). @@ -1986,6 +1992,21 @@ mod tests { } } + /// Cmd+W with the Render Queue focused closes the queue; the composition next to it stays + /// open (it used to be closed, and the queue stayed). + #[test] + fn cmd_w_in_the_render_queue_closes_the_queue_not_the_comp() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let a = app.session.execute("comp.new", json!({"name": "A", "duration": 4})).unwrap()["comp"].as_u64(); + app.show_panel(PanelKind::RenderQueue); + assert_eq!(app.ui.focused, PanelKind::RenderQueue); + invoke(&mut app, &ctx, "file.close", json!({})).unwrap(); + assert!(!app.ui.dock.contains(PanelKind::RenderQueue)); + assert_eq!(app.session.state.active_comp.map(|c| c.0), a); + assert_eq!(app.session.state.open_comps.len(), 1); + } + /// #336: Ctrl+\ (Cmd+\ on macOS) maximizes the application window, and again restores /// it, as in After Effects. #[test] From a7cad22c13a0f7e05708c2279ce88185e3afd7de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:28:22 +0000 Subject: [PATCH 04/60] #1284: right-clicking a Graph Editor keyframe opens its key menu The Graph Editor had no menu for keyframes. Right-click now opens the same key menu as the Timeline, for the commands that apply from the graph: Edit Value, Copy and Paste, selecting equal or neighbouring keys, Hold, Rove Across Time (spatial properties only), Easy Ease, interpolation and Delete. The items run the existing keys.* commands, so undo and the automation ids are unchanged. A right-click on an unselected key selects it first, as a click does. Not covered by an automated test; checked by compiling and by reading the menu against the Timeline's. Claude-Session: https://claude.ai/code/session_0136kZtXrYo5VUWEFxWduwUD --- crates/ui-egui/src/panels/graph.rs | 44 +++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 2dbb98268..41e2c5263 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -453,7 +453,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } else if kresp.clicked() && mods.shift { actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true}))); - } else if (kresp.clicked() || kresp.drag_started()) && !k.sel { + } else if (kresp.clicked() || kresp.drag_started() || kresp.secondary_clicked()) && !k.sel { actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift}))); } // A drag moves every selected key, by how far the pointer went from where the button @@ -485,6 +485,48 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); actions.push(("__endMerge".into(), json!({}))); } + // Right-click: the Timeline's key menu, for the commands that apply from the Graph Editor. + kresp.context_menu(|ui| { + let editable_value = matches!(key.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)); + for (lbl, cmd, params) in [ + ("Edit Value…", "keys.set", kjson.clone()), + ("-", "", json!({})), + ("Copy", "keys.copy", json!({})), + ("Paste", "keys.paste", json!({})), + ("-", "", json!({})), + ("Select Equal Keyframes", "keys.selectEqual", json!({})), + ("Select Previous Keyframes", "keys.selectPrevious", json!({})), + ("Select Following Keyframes", "keys.selectFollowing", json!({})), + ("-", "", json!({})), + ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + ("Rove Across Time", "keys.interpolation", json!({"roving": !key.roving})), + ("-", "", json!({})), + ("Easy Ease", "keys.easyEase", json!({})), + ("Easy Ease In", "keys.easyEaseIn", json!({})), + ("Easy Ease Out", "keys.easyEaseOut", json!({})), + ("-", "", json!({})), + ("Linear", "keys.interpolation", json!({"interpolation": "linear"})), + ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})), + ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})), + ("-", "", json!({})), + ("Delete", "keys.delete", json!({})), + ] { + if lbl == "-" { + ui.separator(); + continue; + } + if lbl == "Rove Across Time" && !c.prop.spatial { + continue; + } + if lbl == "Edit Value…" && !editable_value { + continue; + } + if ui.button(lbl).clicked() { + actions.push((cmd.into(), params)); + ui.close(); + } + } + }); } } From 84589dc28804dd112c7474a1af6282ff99b2d3ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:28:22 +0000 Subject: [PATCH 05/60] #1284: double-clicking a shape tool adds a comp-sized shape layer Double-click a shape tool in the toolbar to add a shape layer the size of the composition, at its centre, for backgrounds, mattes and overlays, as After Effects does. It uses layer.newShape with the tool's kind, so the undo label and the command are the same as drawing one. Covered by tests::a_comp_sized_shape_layer_sits_on_the_comp_centre; the double-click itself is not covered by an automated test. Claude-Session: https://claude.ai/code/session_0136kZtXrYo5VUWEFxWduwUD --- crates/engine/src/tests.rs | 12 ++++++++++++ crates/ui-egui/src/header.rs | 9 +++++++++ crates/ui-egui/src/panels/viewer.rs | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 587379a85..680cbff22 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -246,6 +246,18 @@ fn text_shape_mask_matte_parent() { assert_eq!(info["layers"].as_array().unwrap().len(), 2); } +/// A shape layer the size of the comp and placed at its default position covers the frame: the +/// Shape Tool's double-click (#1284) makes one this way, for backgrounds, mattes and overlays. +#[test] +fn a_comp_sized_shape_layer_sits_on_the_comp_centre() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let sh = s.execute("layer.newShape", json!({"kind": "rect", "size": [400, 300], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone(); + let pos = l.props.prop("transform/position").unwrap().value.clone(); + assert!(matches!(pos, effectcraft_keyframe::Value::Vec3(p) if (p[0] - 200.0).abs() < 1e-9 && (p[1] - 150.0).abs() < 1e-9), "{pos:?}"); +} + /// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn /// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include /// an empty Path and a Gradient Stroke that render. diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1260c9c9e..0c6a9d169 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -82,6 +82,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { { app.ui.status = e; } + // Double-click a shape tool: a shape layer filling the composition, centred, for + // backgrounds, mattes and overlays (as After Effects does). + if cur.is_shape() + && resp.double_clicked() + && let Some((w, h)) = app.session.active_comp().map(|c| (c.width, c.height)) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.newShape", json!({"kind": crate::panels::viewer::shape_kind(cur), "size": [w, h]})) + { + app.ui.status = e; + } if slot.len() > 1 { let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten(); if let Some(seconds) = held_for diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c9..8410bb78c 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2138,7 +2138,7 @@ struct ShapeDrag { } /// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it. -fn shape_kind(tool: Tool) -> &'static str { +pub(crate) fn shape_kind(tool: Tool) -> &'static str { match tool { Tool::Rectangle => "rect", Tool::RoundedRect => "rounded", From f9c3547e57001ee9d876c1620e598a992a6d6b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:28:22 +0000 Subject: [PATCH 06/60] #1284: keep Rove Across Time covered through edits to other keys The report says a roving key goes back to its original time after other keys are edited. Editing the last key's value and then its time keeps the roving key at its share of the path, as the Rove Across Time description says, so this adds a regression test for both edits. It passes on this branch; the reported sequence is not reproduced yet, so the report still needs the exact steps. Claude-Session: https://claude.ai/code/session_0136kZtXrYo5VUWEFxWduwUD --- crates/engine/src/tests_timeline.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 6a74a23b3..94566240a 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -219,6 +219,25 @@ fn interpolation_dialog_temporal_spatial_and_roving() { assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold); } +/// Editing another key's value or time keeps the roving key at its share of the path (#1284: the +/// roving key went back to where it was after edits to other keys). +#[test] +fn roving_key_follows_value_and_time_edits_to_other_keys() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]); + let uid = prop(&s, l, "transform/position").uid; + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap(); + s.execute("keys.interpolation", json!({"roving": true})).unwrap(); + let roving_at = |s: &Session| prop(s, l, "transform/position").keys[1].time.seconds(); + assert!((roving_at(&s) - 1.5).abs() < 0.01, "start: {}", roving_at(&s)); + // The last key's value moves to 800 px: the path is 300 + 500 px, so the roving key is at 300 / 800. + s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "value": [800, 0]})).unwrap(); + assert!((roving_at(&s) - 0.75).abs() < 0.02, "value edit: {}", roving_at(&s)); + // The last key moves to 3 s: the same 300 / 800 share of 3 s. + s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 3.0})).unwrap(); + assert!((roving_at(&s) - 1.125).abs() < 0.02, "time edit: {}", roving_at(&s)); +} + #[test] fn graph_editor_key_and_handle_edits() { let (mut s, l) = setup(); From 048d6c4a25ce6596c97906042a0b59db71e3aa83 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Sat, 24 Oct 2026 14:35:45 +0800 Subject: [PATCH 07/60] Text: duplicate ends the open text edit and selects the copy (#2226) A deselected layer never keeps an open text edit: duplicate now commits any open edit through the same route as text.endEdit before the selection moves, from every route (menu, shortcut, palette, MCP, script). The editor-held keyboard matches the chord against each command's current binding, like Deselect All already did, so rebound shortcuts keep working and keys a focused editor must never surrender stay typing. --- crates/engine/src/commands/edit.rs | 5 ++ crates/engine/src/tests_text_edit.rs | 66 +++++++++++++++++++ crates/ui-egui/src/panels/viewer_text.rs | 21 +++--- crates/ui-egui/tests/it/ui_text_edit.rs | 82 ++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 3630efeee..ad4c10228 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -126,6 +126,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { return super::prop_groups::duplicate_contents(s, &items); } } + // A deselected layer never keeps an open text edit: the copies take the selection, so commit + // an open edit first, exactly as `text.endEdit` (typed text kept, an empty Type-tool layer + // discarded) and as Deselect All does — one call on the command path so every route (menu, + // shortcut, palette, MCP, script) ends the edit and leaves the same final state. + super::text_edit::end_edit(s, &Value::Null)?; let (cid, ids) = layers_p(s, p)?; let new = s.edit("Duplicate", None, |proj, st| { let mut next = proj.next_id; diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index b4d2ff8c0..a44e44856 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -427,3 +427,69 @@ fn deselect_all_discards_an_empty_type_tool_layer() { s.execute("edit.undo", json!({})).unwrap(); assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); } + +#[test] +fn duplicate_ends_an_edit_on_already_committed_text() { + // The engine route (MCP, control channel, script) with text that was committed before the + // edit opened and nothing typed inside it: the edit still ends and the copy is selected. + let (mut s, t) = session("Hello"); + s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap(); + let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + assert!(s.state.text_edit.is_none(), "the open edit ends on a direct edit.duplicate"); + assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and the copy is selected"); + assert_eq!(doc(&s, t).text, "Hello", "the committed text is kept on the original"); + assert_eq!(doc(&s, copy).text, "Hello", "and carried by the copy"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_comp().map_or(0, |c| c.layers.len()), 1, "undo removes the copy"); + assert!(s.state.text_edit.is_none(), "undo does not reopen the edit"); +} + +#[test] +fn duplicate_ends_the_open_edit_and_selects_the_copy() { + let (mut s, t) = session("Hello"); + s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap(); + s.execute("text.insert", json!({"text": "!"})).unwrap(); + assert_eq!(doc(&s, t).text, "Hello!"); + let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + // A deselected layer never keeps an open text edit: Duplicate commits the edit through the + // same route as text.endEdit before the selection moves, so every route (menu, shortcut, + // palette, MCP, script) leaves one final state. + assert!(s.state.text_edit.is_none(), "Duplicate ends the open text edit, as Deselect All does"); + assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and selects the copy"); + assert_eq!(doc(&s, t).text, "Hello!", "the original keeps the typed text"); + assert_eq!(doc(&s, copy).text, "Hello!", "the copy carries it too"); + // The edit ending is no undoable step of its own (as with text.endEdit): undo takes the + // duplicate, it does not reopen the edit or undo the typing. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_comp().map_or(0, |c| c.layers.len()), 1, "undo removed the copy"); + assert_eq!(doc(&s, t).text, "Hello!", "the typed text stays committed"); + assert!(s.state.text_edit.is_none(), "undo does not reopen the edit"); + // With no open edit Duplicate is untouched by all of this: same behavior, no extra steps. + s.execute("edit.duplicate", json!({"layers": [t]})).unwrap(); + assert_eq!(s.active_comp().map_or(0, |c| c.layers.len()), 2, "a plain duplicate still duplicates"); + assert!(s.state.text_edit.is_none()); +} + +#[test] +fn duplicate_of_an_empty_type_tool_layer_discards_it_as_endedit_does() { + let (mut s, _keep) = session("Hello"); + let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); + assert!( + s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)), + "the Type tool opens the edit on its new, empty layer" + ); + let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len()); + let before = count(&s); + s.execute("edit.duplicate", json!({})).unwrap(); + assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it"); + assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is discarded, not duplicated"); + assert!(s.state.selected_layers.is_empty(), "nothing is selected: the layer that would be copied is gone"); + // The edit is committed before Duplicate resolves its layers, so naming the very layer + // the empty edit was on addresses nothing: the commit still happened, the duplicate errors. + let new2 = s.execute("layer.newText", json!({"text": "", "position": [90.0, 90.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute("edit.duplicate", json!({"layers": [new2]})).is_err(), "the named layer is gone once the edit commits"); + assert!(s.state.text_edit.is_none(), "and the edit ended all the same"); + assert_eq!(count(&s), before - 1, "with the empty layer discarded, nothing left to duplicate"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); +} diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index c7169f416..8c77e069a 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -504,14 +504,19 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) { exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns})); } // The app-wide shortcut dispatcher is skipped while this editor holds the - // keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default) - // must be matched here and run through the command dispatcher like undo - // above. Matching the command's current binding, not a fixed key, keeps - // rebound shortcuts working and gives every route one final state; the - // forwarding test also refuses chords a focused editor must never surrender - // (see `forwardable_during_text_edit`). - _ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => { - let _ = crate::menus::invoke(app, &ctx, "edit.deselectAll", json!({})); + // keyboard, so commands that end the edit by moving the selection — Deselect + // All (Cmd/Ctrl+Shift+A or F2 by default) and Duplicate (Cmd/Ctrl+D) — must be + // matched here and run through the command dispatcher like undo above. + // Matching each command's current binding, not a fixed key, keeps rebound + // shortcuts working and gives every route one final state; a chord bound to + // both commands resolves in list order. The forwarding test also refuses + // chords a focused editor must never surrender (see + // `forwardable_during_text_edit`). + _ if crate::menus::forwardable_during_text_edit(m, key) + && let Some(cmd_id) = + ["edit.deselectAll", "edit.duplicate"].into_iter().find(|id| crate::menus::chord_matches(&app.session, id, key, m)) => + { + let _ = crate::menus::invoke(app, &ctx, cmd_id, json!({})); } _ => {} } diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index ed5baca2d..65eccb589 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -724,3 +724,85 @@ fn deselect_all_menu_item_follows_the_edit_while_the_project_panel_is_focused() // With no edit, the item deselects Project items again: nothing selected there, nothing to do. assert!(!enabled(&h), "Deselect All follows the empty Project selection once the edit is over"); } + +/// Layer ids of the active comp, bottom to top. +fn layer_ids(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.active_comp().map_or(vec![], |c| c.layers.iter().map(|l| l.id.0).collect()) +} + +#[test] +fn duplicate_chord_ends_a_type_tool_edit_and_selects_the_copy() { + let (mut h, lid) = type_tool_edit(app(), "Hello"); + key(&mut h, Key::D, Modifiers::COMMAND); + let s = &h.state().session; + assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit, as Deselect All does"); + let copy = *s.state.selected_layers.first().expect("the copy is selected"); + assert_ne!(copy.0, lid, "the copy (not the edited original) holds the selection"); + assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the original keeps its text"); + assert_eq!(layer_doc(s, copy).unwrap().text, "Hello", "the copy carries it too"); +} + +#[test] +fn duplicate_chord_ends_an_edit_started_while_the_project_panel_is_focused() { + let (mut h, lid) = project_focused_text_edit("Hi"); + key(&mut h, Key::D, Modifiers::COMMAND); + let s = &h.state().session; + assert!(s.state.text_edit.is_none(), "Cmd+D ends the edit even with Project focused"); + let copy = s.state.selected_layers.first().expect("the copy is selected"); + assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hi", "the typed text is kept"); + assert_eq!(layer_doc(s, *copy).unwrap().text, "Hi", "on the copy as well"); +} + +#[test] +fn rebound_duplicate_chord_forwards_from_inside_the_edit() { + let mut a = app(); + a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+D"]})).unwrap(); + let (mut h, lid) = type_tool_edit(a, "Hello"); + // The default key no longer belongs to Duplicate: while the editor holds the keyboard a + // chord that matches no command does nothing, and the current binding is what forwards. + let n = layer_ids(&h).len(); + key(&mut h, Key::D, Modifiers::COMMAND); + assert!(h.state().session.state.text_edit.is_some(), "Cmd+D was rebound away: the edit stays open"); + assert_eq!(layer_ids(&h).len(), n, "and nothing was duplicated"); + key(&mut h, Key::D, Modifiers { alt: true, ..Modifiers::COMMAND }); + let s = &h.state().session; + assert!(s.state.text_edit.is_none(), "the rebound chord ends the edit"); + assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the text is kept"); + assert_eq!(layer_ids(&h).len(), n + 1, "and the copy exists"); +} + +#[test] +fn duplicate_rebound_onto_a_bare_letter_stays_typing() { + let mut a = app(); + a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["D"]})).unwrap(); + let (mut h, lid) = type_tool_edit(a, "Hello"); + let n = layer_ids(&h).len(); + // A binding without a command modifier is text as far as the editor is concerned: the editor + // must not surrender a key that types (same invariant as the app-wide dispatcher). + key(&mut h, Key::D, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_some(), "a bare-letter Duplicate binding does not end the edit"); + assert_eq!(layer_ids(&h).len(), n, "and does not duplicate"); + type_text(&mut h, "D"); + let (lid2, text, _, _) = edited(&h); + assert_eq!(lid2, lid, "the same edit on the same layer continues"); + assert_eq!(text, "HelloD", "and the letter typed into the text"); +} + +#[test] +fn a_chord_bound_to_both_forwarded_commands_resolves_in_list_order() { + let mut a = app(); + // Shortcuts may hold one chord for several commands (the conflict is a toast, not a + // refusal), so the editor's forwarding resolves a shared chord in list order — Deselect + // All before Duplicate. This pins that documented contract: the shared chord must end the + // edit like Deselect All, without running Duplicate behind it. + a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["Cmd+Alt+G"]})).unwrap(); + a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+G"]})).unwrap(); + let (mut h, lid) = type_tool_edit(a, "Hello"); + let n = layer_ids(&h).len(); + key(&mut h, Key::G, Modifiers { alt: true, ..Modifiers::COMMAND }); + let s = &h.state().session; + assert!(s.state.text_edit.is_none(), "the shared chord still ends the edit"); + assert!(s.state.selected_layers.is_empty(), "Deselect All wins the shared chord: nothing is selected"); + assert_eq!(layer_ids(&h).len(), n, "so the chord did not duplicate"); + assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "and the text is kept"); +} From 9928f6181bfdbc08a8018a537b545f97a3bddd7a Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Wed, 28 Oct 2026 00:22:35 +0800 Subject: [PATCH 08/60] Text: duplicate during a text edit copies the layer, not a selected effect (#2226) Opening the editor keeps the property selection, so Cmd+D while typing duplicated an effect, shape item or text animator selected before and left the edit open. An open edit now comes first, as it does for Clear: Duplicate ends it and copies the edited layer. With no edit open, selected effects and items keep their precedence, and the command's description says so. --- crates/engine/src/cmd/edit.rs | 16 ++++++++-- crates/engine/src/tests/text_edit.rs | 39 +++++++++++++++++++++++++ crates/ui-egui/tests/it/ui_text_edit.rs | 22 ++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e9ec7eca1..8d4bde60c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -105,8 +105,10 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) { } fn duplicate(s: &mut Session, p: &Value) -> Result { - // Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). - if p.get("layers").is_none() { + // Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). An open text edit + // comes first, as it does for Clear: opening the editor keeps the property selection, but + // while typing, Duplicate means the edited layer, not an effect or item selected before. + if p.get("layers").is_none() && s.state.text_edit.is_none() { let fx = super::effect::selected_effects(s); if !fx.is_empty() { let mut out = vec![]; @@ -961,7 +963,15 @@ pub fn specs() -> Vec { paste_reversed ), cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete), - cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate), + cmd!( + "edit.duplicate", + "Duplicate", + ["Edit"], + Some("Cmd+D"), + "{layers?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)", + has_layers, + duplicate + ), cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split), cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift), cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract), diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 14fef7576..64ee2f0e3 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1234,6 +1234,45 @@ fn duplicate_ends_an_edit_on_already_committed_text() { assert!(s.state.text_edit.is_none(), "undo does not reopen the edit"); } +#[test] +fn duplicate_during_an_edit_copies_the_layer_not_a_selected_property_group() { + use effectcraft_project::LayerId; + // Opening the editor by command, as a Type-tool click does, keeps the property selection, so + // an effect, shape item or text animator can still be selected while the user types. + for what in ["effect", "shape item", "text animator"] { + let (mut s, t) = session("Hello"); + let (owner, path) = match what { + "effect" => { + s.execute("effect.apply", json!({"layers": [t], "effect": "Gaussian Blur"})).unwrap(); + (t, "effects") + } + "shape item" => (s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(), "contents"), + _ => { + s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["opacity"]})).unwrap(); + (t, "text/animators") + } + }; + let items = + |s: &Session| s.active_comp().unwrap().layer(LayerId(owner)).unwrap().props.group(path).unwrap().groups().map(|g| g.uid).collect::>(); + let item = items(&s)[0]; + let n = items(&s).len(); + let layers = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len()); + let before = layers(&s); + s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap(); + begin_edit_at(&mut s, t, 5); + let r = s.execute("edit.duplicate", json!({})).unwrap(); + assert!(s.state.text_edit.is_none(), "{what}: Duplicate ends the open edit"); + assert_eq!(items(&s).len(), n, "{what}: the selected {what} is not duplicated"); + assert_eq!(layers(&s), before + 1, "{what}: the edited layer is"); + assert_eq!(s.state.selected_layers, vec![LayerId(r[0].as_u64().unwrap())], "{what}: and its copy is selected"); + // With no edit open, the selected item keeps its precedence over the layers. + s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap(); + s.execute("edit.duplicate", json!({})).unwrap(); + assert_eq!(items(&s).len(), n + 1, "{what}: outside an edit Duplicate copies the selected {what}"); + assert_eq!(layers(&s), before + 1, "{what}: and no layer"); + } +} + #[test] fn duplicate_ends_the_open_edit_and_selects_the_copy() { let (mut s, t) = editing("Hello", 5); diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 0612e2923..a63631e8a 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -791,6 +791,28 @@ fn duplicate_rebound_onto_a_bare_letter_stays_typing() { assert_eq!(text, "HelloD", "and the letter typed into the text"); } +#[test] +fn duplicate_chord_copies_the_edited_layer_not_an_effect_still_selected() { + let mut a = app(); + let lid = a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap(); + // Applying the blur selects it, as clicking it in Effect Controls does. + a.session.execute("effect.apply", json!({"layers": [lid], "effect": "Gaussian Blur"})).unwrap(); + a.ui.tool = effectcraft_ui_egui::state::Tool::Type; + let mut h = harness(a); + h.run_steps(3); + let comp = rect(&h, "viewer.comp"); + click(&mut h, comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0), Modifiers::NONE); + assert_eq!(edited(&h).0, lid, "the Type tool's click opens the text layer for editing"); + assert!(!h.state().session.state.selected_props.is_empty(), "and the blur stays selected"); + let n = layer_ids(&h).len(); + key(&mut h, Key::D, Modifiers::COMMAND); + let s = &h.state().session; + assert!(s.state.text_edit.is_none(), "Duplicate ends the edit"); + assert_eq!(layer_ids(&h).len(), n + 1, "and copies the edited layer"); + let blurs = s.active_comp().unwrap().layer(LayerId(lid)).unwrap().effects().map_or(0, |fx| fx.groups().count()); + assert_eq!(blurs, 1, "not the blur"); +} + #[test] fn a_chord_bound_to_both_forwarded_commands_resolves_in_list_order() { let mut a = app(); From eeedd6b540e229fdfba987a6b842e4e6cb48b2d1 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Wed, 28 Oct 2026 00:26:52 +0800 Subject: [PATCH 09/60] Text: duplicate during a text edit leaves no effect selected on the original (#2226) Duplicate passes over the property selection an open edit kept and copies the edited layer, but left that selection in place on the original. With the copy selected, the next Duplicate copied the original's effect, shape item or text animator, and Clear would have removed it. Ending the edit through Duplicate now clears that property selection along with it. --- crates/engine/src/cmd/edit.rs | 8 +++++++- crates/engine/src/tests/text_edit.rs | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8d4bde60c..585e4459e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -105,10 +105,11 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) { } fn duplicate(s: &mut Session, p: &Value) -> Result { + let editing = s.state.text_edit.is_some(); // Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). An open text edit // comes first, as it does for Clear: opening the editor keeps the property selection, but // while typing, Duplicate means the edited layer, not an effect or item selected before. - if p.get("layers").is_none() && s.state.text_edit.is_none() { + if p.get("layers").is_none() && !editing { let fx = super::effect::selected_effects(s); if !fx.is_empty() { let mut out = vec![]; @@ -151,6 +152,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { relink_copies(comp, &map); proj.next_id = next; st.selected_layers = created.clone(); + // The property selection the edit kept was passed over above and belongs to the + // originals: left in place, the next Duplicate or Clear would act on it, not the copies. + if editing { + st.selected_props.clear(); + } Ok(created) })?; Ok(json!(new.iter().map(|l| l.0).collect::>())) diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 64ee2f0e3..1713cd459 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1265,11 +1265,16 @@ fn duplicate_during_an_edit_copies_the_layer_not_a_selected_property_group() { assert_eq!(items(&s).len(), n, "{what}: the selected {what} is not duplicated"); assert_eq!(layers(&s), before + 1, "{what}: the edited layer is"); assert_eq!(s.state.selected_layers, vec![LayerId(r[0].as_u64().unwrap())], "{what}: and its copy is selected"); + // The selection the edit passed over goes with it: the next Duplicate copies the selected + // copy rather than reaching back to the original's {what}. + s.execute("edit.duplicate", json!({})).unwrap(); + assert_eq!(items(&s).len(), n, "{what}: a second Duplicate leaves the original's {what} alone"); + assert_eq!(layers(&s), before + 2, "{what}: and copies the copy"); // With no edit open, the selected item keeps its precedence over the layers. s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap(); s.execute("edit.duplicate", json!({})).unwrap(); assert_eq!(items(&s).len(), n + 1, "{what}: outside an edit Duplicate copies the selected {what}"); - assert_eq!(layers(&s), before + 1, "{what}: and no layer"); + assert_eq!(layers(&s), before + 2, "{what}: and no layer"); } } From 0e3b894cd906befdd0c01f2df1201b1975eaaae6 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Wed, 28 Oct 2026 00:30:00 +0800 Subject: [PATCH 10/60] Text: the text-edit tests count layers with the shared helper; forwarding docs name Duplicate (#2226) The engine's text-edit tests repeated one layer-count closure in five places; they now use the layer_count helper the 3D tests already had. The doc of forwardable_during_text_edit and of the Type-tool test helper still spoke of Deselect All alone, though the editor now forwards Duplicate too. --- crates/engine/src/tests/text_edit.rs | 34 ++++++++++++------------- crates/engine/src/tests/three_d.rs | 2 +- crates/ui-egui/src/menus/shortcuts.rs | 4 +-- crates/ui-egui/tests/it/ui_text_edit.rs | 2 +- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 1713cd459..f129479f6 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -7,6 +7,7 @@ use serde_json::json; use crate::Session; use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc}; +use crate::tests::three_d::layer_count; fn session(text: &str) -> (Session, u64) { let mut s = Session::default(); @@ -278,7 +279,7 @@ fn type_tool_layers_point_box_and_empty_cleanup() { let pos = l.props.prop("transform/position").unwrap().value.as_vec3(); assert_eq!([pos[0], pos[1]], [190.0, 110.0]); s.execute("text.endEdit", json!({})).unwrap(); - assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(layer_count(&s), 1); // Resizing the box (layer space) is an attribute too. s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap(); assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0])); @@ -1209,14 +1210,13 @@ fn deselect_all_discards_an_empty_type_tool_layer() { s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)), "the Type tool opens the edit on its new, empty layer" ); - let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len()); - let before = count(&s); + let before = layer_count(&s); s.execute("edit.deselectAll", json!({})).unwrap(); assert!(s.state.text_edit.is_none()); assert!(s.state.selected_layers.is_empty()); - assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does"); + assert_eq!(layer_count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does"); s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); + assert_eq!(layer_count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); } #[test] @@ -1230,7 +1230,7 @@ fn duplicate_ends_an_edit_on_already_committed_text() { assert_eq!(doc(&s, t).text, "Hello", "the committed text is kept on the original"); assert_eq!(doc(&s, copy).text, "Hello", "and carried by the copy"); s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.active_comp().map_or(0, |c| c.layers.len()), 1, "undo removes the copy"); + assert_eq!(layer_count(&s), 1, "undo removes the copy"); assert!(s.state.text_edit.is_none(), "undo does not reopen the edit"); } @@ -1256,25 +1256,24 @@ fn duplicate_during_an_edit_copies_the_layer_not_a_selected_property_group() { |s: &Session| s.active_comp().unwrap().layer(LayerId(owner)).unwrap().props.group(path).unwrap().groups().map(|g| g.uid).collect::>(); let item = items(&s)[0]; let n = items(&s).len(); - let layers = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len()); - let before = layers(&s); + let before = layer_count(&s); s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap(); begin_edit_at(&mut s, t, 5); let r = s.execute("edit.duplicate", json!({})).unwrap(); assert!(s.state.text_edit.is_none(), "{what}: Duplicate ends the open edit"); assert_eq!(items(&s).len(), n, "{what}: the selected {what} is not duplicated"); - assert_eq!(layers(&s), before + 1, "{what}: the edited layer is"); + assert_eq!(layer_count(&s), before + 1, "{what}: the edited layer is"); assert_eq!(s.state.selected_layers, vec![LayerId(r[0].as_u64().unwrap())], "{what}: and its copy is selected"); // The selection the edit passed over goes with it: the next Duplicate copies the selected // copy rather than reaching back to the original's {what}. s.execute("edit.duplicate", json!({})).unwrap(); assert_eq!(items(&s).len(), n, "{what}: a second Duplicate leaves the original's {what} alone"); - assert_eq!(layers(&s), before + 2, "{what}: and copies the copy"); + assert_eq!(layer_count(&s), before + 2, "{what}: and copies the copy"); // With no edit open, the selected item keeps its precedence over the layers. s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap(); s.execute("edit.duplicate", json!({})).unwrap(); assert_eq!(items(&s).len(), n + 1, "{what}: outside an edit Duplicate copies the selected {what}"); - assert_eq!(layers(&s), before + 2, "{what}: and no layer"); + assert_eq!(layer_count(&s), before + 2, "{what}: and no layer"); } } @@ -1294,12 +1293,12 @@ fn duplicate_ends_the_open_edit_and_selects_the_copy() { // The edit ending is no undoable step of its own (as with text.endEdit): undo takes the // duplicate, it does not reopen the edit or undo the typing. s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.active_comp().map_or(0, |c| c.layers.len()), 1, "undo removed the copy"); + assert_eq!(layer_count(&s), 1, "undo removed the copy"); assert_eq!(doc(&s, t).text, "Hello!", "the typed text stays committed"); assert!(s.state.text_edit.is_none(), "undo does not reopen the edit"); // With no open edit Duplicate is untouched by all of this: same behavior, no extra steps. s.execute("edit.duplicate", json!({"layers": [t]})).unwrap(); - assert_eq!(s.active_comp().map_or(0, |c| c.layers.len()), 2, "a plain duplicate still duplicates"); + assert_eq!(layer_count(&s), 2, "a plain duplicate still duplicates"); assert!(s.state.text_edit.is_none()); } @@ -1311,18 +1310,17 @@ fn duplicate_of_an_empty_type_tool_layer_discards_it_as_endedit_does() { s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)), "the Type tool opens the edit on its new, empty layer" ); - let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len()); - let before = count(&s); + let before = layer_count(&s); s.execute("edit.duplicate", json!({})).unwrap(); assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it"); - assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is discarded, not duplicated"); + assert_eq!(layer_count(&s), before - 1, "a Type-tool layer left empty is discarded, not duplicated"); assert!(s.state.selected_layers.is_empty(), "nothing is selected: the layer that would be copied is gone"); // The edit is committed before Duplicate resolves its layers, so naming the very layer // the empty edit was on addresses nothing: the commit still happened, the duplicate errors. let new2 = s.execute("layer.newText", json!({"text": "", "position": [90.0, 90.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert!(s.execute("edit.duplicate", json!({"layers": [new2]})).is_err(), "the named layer is gone once the edit commits"); assert!(s.state.text_edit.is_none(), "and the edit ended all the same"); - assert_eq!(count(&s), before - 1, "with the empty layer discarded, nothing left to duplicate"); + assert_eq!(layer_count(&s), before - 1, "with the empty layer discarded, nothing left to duplicate"); s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); + assert_eq!(layer_count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); } diff --git a/crates/engine/src/tests/three_d.rs b/crates/engine/src/tests/three_d.rs index 07a48af1c..dc28ae42a 100644 --- a/crates/engine/src/tests/three_d.rs +++ b/crates/engine/src/tests/three_d.rs @@ -26,7 +26,7 @@ fn auto_orient_command() { assert!(!effectcraft_render::three_d::camera::is_two_node(&s.active_comp().unwrap().layers[0])); } -fn layer_count(s: &Session) -> usize { +pub(crate) fn layer_count(s: &Session) -> usize { s.active_comp().unwrap().layers.len() } diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index 7e3dfcec2..f931667ea 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -136,8 +136,8 @@ pub fn chord_matches(session: &effectcraft_engine::Session, command: &str, key: bindings(session).into_iter().any(|(m, k, id, _)| id == command && k == key && mods_match(m, mods)) } -/// Whether a Deselect All chord matched by [`chord_matches`] may run while a text editor holds -/// the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as +/// Whether a Deselect All or Duplicate chord matched by [`chord_matches`] may run while a text +/// editor holds the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as /// such for undo and the rest), and function keys never type, so those forward from the editor; /// a chord that could instead produce a character — a bare or Shift-only letter, digit or /// punctuation key, as from a pathological bare-letter rebinding — stays with the editor so diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index a63631e8a..4fe647e54 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -580,7 +580,7 @@ fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_ } /// Opens a fresh Type-tool edit holding `text` and returns the harness with the new layer id, -/// so the Deselect All tests start from the same state. +/// so the Deselect All and Duplicate tests start from the same state. fn type_tool_edit(a: EffectcraftApp, text: &str) -> (Harness<'static, EffectcraftApp>, u64) { let mut h = harness(a); h.run_steps(3); From c8b07b4b06d9293690bd4ca9ae35882ecc845632 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Wed, 28 Oct 2026 00:50:43 +0800 Subject: [PATCH 11/60] Text: duplicate during a text edit leaves no keyframe or mask vertex selected on the original (#2226) Opening the editor keeps the keyframe and mask-vertex selection as well as the property one, and Duplicate cleared only the properties. With the copy selected, Clear then deleted a key or vertex of the original instead of the copy. During an edit the copies now take the selection the way a click on them does: layer.select's rule, moved into one helper both use. With no edit open, Duplicate's selection is unchanged. --- crates/engine/src/cmd/edit.rs | 10 +++++--- crates/engine/src/cmd/layer.rs | 1 + crates/engine/src/cmd/layer/select.rs | 16 ++++++++---- crates/engine/src/tests/text_edit.rs | 35 +++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 585e4459e..aaef8052a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -151,11 +151,13 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { } relink_copies(comp, &map); proj.next_id = next; - st.selected_layers = created.clone(); - // The property selection the edit kept was passed over above and belongs to the - // originals: left in place, the next Duplicate or Clear would act on it, not the copies. + // The property, keyframe and mask-vertex selection the edit kept belongs to the originals; + // left in place, the next Duplicate or Clear would act on it, not on the copies. So during + // an edit the copies take the selection the way a click on them would. if editing { - st.selected_props.clear(); + super::layer::select_only(st, created.clone()); + } else { + st.selected_layers = created.clone(); } Ok(created) })?; diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f6d2feb04..7ad73b8ba 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -28,6 +28,7 @@ pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place, pub(crate) use parent::parent_fixes; use parent::set_parent; use replace::{has_replace_source, replace_source}; +pub(crate) use select::select_only; use select::{select, select_next, select_prev, select_step}; use switches::{blend, rename, set_comment, set_switch, track_matte}; use timing::{arrange, move_frames, precompose, slip, timing}; diff --git a/crates/engine/src/cmd/layer/select.rs b/crates/engine/src/cmd/layer/select.rs index b7fded70d..8008bcb7e 100644 --- a/crates/engine/src/cmd/layer/select.rs +++ b/crates/engine/src/cmd/layer/select.rs @@ -25,15 +25,21 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result { } } } else { - s.state.selected_layers = ids; - s.state.selected_props.clear(); - s.state.selected_keys.clear(); - let keep = s.state.selected_layers.clone(); - s.state.selected_vertices.retain(|v| keep.contains(&v.layer)); + select_only(&mut s.state, ids); } Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::>())) } +/// Make `ids` the whole layer selection, as a plain click on them does: no property or keyframe +/// stays selected, and mask vertices stay selected only on layers that are still selected. +pub(crate) fn select_only(st: &mut crate::EditorState, ids: Vec) { + st.selected_layers = ids; + st.selected_props.clear(); + st.selected_keys.clear(); + let keep = st.selected_layers.clone(); + st.selected_vertices.retain(|v| keep.contains(&v.layer)); +} + pub(super) fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result { let comp = s.active_comp().ok_or(EngineError::NoComp)?; if comp.layers.is_empty() { diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index f129479f6..176fe2ae6 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1277,6 +1277,41 @@ fn duplicate_during_an_edit_copies_the_layer_not_a_selected_property_group() { } } +#[test] +fn duplicate_during_an_edit_leaves_no_key_or_vertex_selected_on_the_original() { + use effectcraft_project::LayerId; + // Opening the editor keeps the keyframe and mask-vertex selection too. Once the copy takes the + // selection, Clear deletes the copy, not a key or a vertex of the original. + for what in ["keyframe", "mask vertex"] { + let (mut s, t) = session("Hello"); + let mask = match what { + "keyframe" => { + for time in [0.0, 1.0] { + s.execute("prop.addKey", json!({"layer": t, "path": "transform/opacity", "time": time, "value": 50})).unwrap(); + } + s.execute("keys.select", json!({"keys": [{"layer": t, "path": "transform/opacity", "time": 0.0}]})).unwrap(); + None + } + // The Pen leaves the new mask's last vertex selected. + _ => Some(s.execute("mask.new", json!({"layer": t, "vertices": [[0, 0], [40, 0], [40, 40]], "closed": true})).unwrap()["mask"].as_u64().unwrap()), + }; + let original = |s: &Session| { + let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap(); + match mask { + None => l.props.prop("transform/opacity").unwrap().keys.len(), + Some(m) => l.props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().vertices.len(), + } + }; + let (n, before) = (original(&s), layer_count(&s)); + begin_edit_at(&mut s, t, 5); + let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "{what}: the copy is selected"); + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!(layer_count(&s), before, "{what}: Clear deletes the selected copy"); + assert_eq!(original(&s), n, "{what}: and leaves the original's {what} alone"); + } +} + #[test] fn duplicate_ends_the_open_edit_and_selects_the_copy() { let (mut s, t) = editing("Hello", 5); From 2856803d5984ad66e96cb1c32cebda993e12659c Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 16:03:19 -0400 Subject: [PATCH 12/60] Timeline: Shift-click on a group row adds it to the selection (#1751) --- crates/ui-egui/src/panels/timeline.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ea18c7f1e..b06ba14e3 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2721,7 +2721,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click()); app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name); if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 { - actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); + // Shift adds the group to the selection, as on a property row (#1751). + let add = ui.input(|i| i.modifiers.shift); + actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false, "add": add}))); } if gr.double_clicked() { if fx.is_some() { From a2c455091f37fdd75797b73ccaf239000126aa09 Mon Sep 17 00:00:00 2001 From: Anubhav Rai Date: Tue, 27 Oct 2026 23:14:37 +0530 Subject: [PATCH 13/60] automation: a confined agent can't queue a render output outside the roots (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderQueue.add` and `render.preRender` without an `output` take the default from where the project sits, which for a project that has never been saved is the home folder — outside every `--automation-read-root` / `--automation-write-root`. The item was queued anyway, and only `renderQueue.render` refused it, so an agent learned about it one command too late and with a queue it had to clean up. Both commands now look the project up first (`Lookup::DefaultOutput`) and are refused on an unsaved project, naming `output` as the way through. A project saved inside the roots keeps its old behaviour: the default goes beside its file and what it writes is checked when the queue is rendered. Co-Authored-By: Claude Opus 5 --- crates/automation/src/backend.rs | 13 +++++----- crates/automation/src/confine.rs | 41 +++++++++++++++++++++++++------- crates/automation/src/tests.rs | 34 ++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index c3947c876..f6ad65558 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -4,7 +4,7 @@ use effectcraft_engine::Session; use effectcraft_engine::time::Tick; use serde_json::{Value, json}; -use crate::confine::Confinement; +use crate::confine::{Confinement, Lookup}; use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png}; pub struct Backend { @@ -63,12 +63,11 @@ impl Backend { pub fn exec(&mut self, id: &str, params: Value) -> Result { let params = if params.is_null() { json!({}) } else { params }; if let Some(c) = self.confinement.clone() { - let lookup = match c.check_command(id, ¶ms)? { - Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)), - Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)), - None => None, - }; - if let Some((lookup, found)) = lookup { + for lookup in c.check_command(id, ¶ms)? { + let found = match lookup { + Lookup::ProjectFile | Lookup::DefaultOutput => self.run("project.summary", json!({}))?, + Lookup::RenderOutputs => self.run("renderQueue.list", json!({}))?, + }; c.check_lookup(&lookup, &found)?; } } diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index f396b3da0..af8a69089 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -6,8 +6,10 @@ //! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside //! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too, //! and commands that write to a file they are not given (Save, Increment and Save, rendering the -//! Render Queue) are checked against where they would write. Commands that run code or open -//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined. +//! Render Queue) are checked against where they would write. Queueing a render without naming +//! its output needs a saved project, because the default output of an unsaved one is the home +//! folder. Commands that run code or open other applications (scripts, Execute File, Reveal in +//! Finder…) are refused while confined. use std::path::{Component, Path, PathBuf}; @@ -89,11 +91,17 @@ const WRITES: &[(&str, &[&str])] = &[ /// Commands that write where they are not told: the project's file, the Render Queue's outputs. const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"]; +/// Commands that queue a render, whose output defaults to the project's folder when not given. +const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender"]; + /// What must be looked up before an implicitly writing command may run. #[derive(Debug, PartialEq, Eq)] pub enum Lookup { /// The open project's file (`project.summary` → `path`). ProjectFile, + /// That the project has a file at all (`project.summary` → `path`), for a queued render whose + /// output the agent did not name: that default is derived from where the project sits. + DefaultOutput, /// Every output of the queued Render Queue items (`renderQueue.list`). RenderOutputs, } @@ -134,7 +142,7 @@ impl Confinement { /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. - pub fn check_command(&self, id: &str, params: &Value) -> Result> { + pub fn check_command(&self, id: &str, params: &Value) -> Result> { if REFUSED.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } @@ -147,11 +155,11 @@ impl Confinement { if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) { return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined"))); } - if self.check_command(inner, &inner_params)?.is_some() { + if !self.check_command(inner, &inner_params)?.is_empty() { return Err(Error::BadArgs(format!("`{inner}` writes to its project's or queue's files: run it on its own while file access is confined"))); } } - return Ok(None); + return Ok(vec![]); } let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) { (Some(k), _) => (k, Access::Write), @@ -188,10 +196,19 @@ impl Confinement { if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given { return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined"))); } - if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { - return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs })); + let mut lookups = vec![]; + // An output the agent did not name is derived from where the project sits: the Default + // Output Folder preference when that is set, beside the project file otherwise — and the + // home folder when the project has never been saved, outside every root (#2479). Only + // that last case is certainly outside, and reading the preference here would duplicate + // the engine's precedence, so an unsaved project is told to name its output instead. + if QUEUES_AN_OUTPUT.contains(&id) && !given { + lookups.push(Lookup::DefaultOutput); } - Ok(None) + if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { + lookups.push(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs }); + } + Ok(lookups) } /// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result. @@ -202,6 +219,14 @@ impl Confinement { Some(p) => self.check(p, Access::Write).map(drop), None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())), }, + Lookup::DefaultOutput => match found.get("path").and_then(Value::as_str) { + // Saved: the default goes beside the project file, inside the roots, and what it + // writes is checked when the queue is rendered. + Some(_) => Ok(()), + None => Err(Error::BadArgs( + "the project has no file yet, so this would queue its default output (the home folder) outside the automation write roots: give `output`, or save the project inside a write root first".into(), + )), + }, Lookup::RenderOutputs => { let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6e622b377..e3282dc71 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1380,3 +1380,37 @@ fn automation_roots_confine_file_access() { assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); } + +/// #2479: a queued output the agent didn't name is derived from where the project sits, so on a +/// project that has never been saved it lands in the home folder, outside every automation root. +/// Queueing it there is refused, rather than leaving an item that can never be rendered. +#[test] +fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-mcp-default-out-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let write = base.join("out"); + std::fs::create_dir_all(&write).unwrap(); + let p = |f: &str| write.join(f).to_string_lossy().into_owned(); + let mut s = server(); + s.backend().confine(Confinement::new(&[], std::slice::from_ref(&write)).unwrap()); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}})); + // Never saved: neither way of queueing a render may fall back to the home folder. + for command in ["renderQueue.add", "render.preRender"] { + let (c, err) = call(&mut s, "execute_command", json!({"command": command, "params": {"comp": "A"}})); + assert!(err, "`{command}` without an output should be refused: {c:?}"); + let msg = c[0]["text"].as_str().unwrap_or_default(); + assert!(msg.contains("the project has no file yet"), "`{command}`: {msg}"); + } + let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); + assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}"); + // Naming an output inside a write root is how it is done. + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}})); + // Once the project is saved there, the default output (beside it) is queued as before. + call_json(&mut s, "save_project", json!({"path": p("proj.ecproj")})); + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}})); + let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); + let out = queue["items"][1]["outputPath"].as_str().unwrap_or_default(); + assert!(out.starts_with(&*write.to_string_lossy()), "{queue}"); + let _ = std::fs::remove_dir_all(&base); +} From f9fb59f29c3f8e2c85b5d5b8b608292dd9bd4e8d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 16:03:20 -0400 Subject: [PATCH 14/60] Timeline: test Shift-selecting and deleting several Puppet pins (#1751) --- .../ui-egui/tests/ui_timeline_group_select.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 crates/ui-egui/tests/ui_timeline_group_select.rs diff --git a/crates/ui-egui/tests/ui_timeline_group_select.rs b/crates/ui-egui/tests/ui_timeline_group_select.rs new file mode 100644 index 000000000..0a553976b --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_group_select.rs @@ -0,0 +1,83 @@ +//! Shift-clicking a group row in the Timeline adds it to the selection, as on a property row, so +//! several Puppet pins can be selected and deleted together (#1751). + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Modifiers, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> (Harness<'static, EffectcraftApp>, u64, [u64; 3]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap(); + let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": layer, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap(); + let pins = [pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::Timeline); + app.ui.timeline.open_layers.insert(layer); + // Twirl open every group down to the pins. + fn all(g: &PropGroup, out: &mut Vec) { + for c in g.groups() { + out.push(c.uid); + all(c, out); + } + } + let mut open = vec![]; + if let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) { + all(&l.props, &mut open); + } + app.ui.timeline.open_groups.extend(open); + let mut h = Harness::builder().with_size(vec2(1600.0, 1100.0)).build_eframe(|_| app); + h.run_steps(4); + (h, layer, pins) +} + +fn row(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Pos2 { + let id = format!("timeline.group.{uid}.name"); + let e = h.state().auto.find(&id).unwrap_or_else(|| panic!("no {id}")); + egui::pos2(e.rect[0] + e.rect[2].min(40.0) / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, uid: u64, modifiers: Modifiers) { + let at = row(h, uid); + h.input_mut().events.push(egui::Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(egui::Event::PointerMoved(at)); + h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(3); + h.input_mut().events.push(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.step(); +} + +fn selected(h: &Harness<'_, EffectcraftApp>, pins: [u64; 3]) -> Vec { + h.state().session.state.selected_props.iter().map(|(_, u)| *u).filter(|u| pins.contains(u)).collect() +} + +fn pin_count(h: &Harness<'_, EffectcraftApp>, layer: u64) -> usize { + let l = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + l.effects().unwrap().groups().map(effectcraft_engine::effects::puppet::pin_count).sum() +} + +#[test] +fn shift_click_adds_puppet_pins_and_delete_removes_them_together() { + let (mut h, layer, pins) = harness(); + let [a, b, c] = pins; + click(&mut h, a, Modifiers::NONE); + assert_eq!(selected(&h, pins), [a]); + click(&mut h, b, Modifiers::SHIFT); + assert_eq!(selected(&h, pins), [a, b], "Shift-click keeps the first pin"); + click(&mut h, c, Modifiers::NONE); + assert_eq!(selected(&h, pins), [c], "a plain click selects only the clicked pin"); + + click(&mut h, a, Modifiers::NONE); + click(&mut h, b, Modifiers::SHIFT); + h.state_mut().session.execute("edit.clear", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(pin_count(&h, layer), 1, "Delete removes both selected pins"); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).is_some(), "the layer stays"); + h.state_mut().session.undo(); + assert_eq!(pin_count(&h, layer), 3, "one Undo restores both pins"); +} From ca5e2003a7cca0e45e3bf5b7eb9779dcc495a8aa Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 17:03:20 -0400 Subject: [PATCH 15/60] Puppet: a value edit on one selected pin reaches the other selected pins (#1751) --- crates/engine/src/cmd/prop.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 00232967e..0c0d4cd22 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -228,7 +228,8 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va /// property (by match path, `transform/position`, `effects/#n/`) of every other /// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty /// when `selected` is not set or the edited layer isn't selected. After Effects applies a value -/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491). +/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491). A parameter +/// of a selected Puppet pin also reaches the same parameter of the other selected pins (#1751). fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result> { if b_p(p, "selected") != Some(true) { return Ok(vec![]); @@ -241,14 +242,31 @@ fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result = s + .state .selected_layers .iter() .filter(|o| **o != lid) .filter_map(|o| comp.layer(*o)) .filter(|l| !l.switches.locked) - .filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid))) - .collect()) + .filter_map(|l| l.props.prop(&path).filter(same_kind).map(|q| (l.id, q.uid))) + .collect(); + // A parameter of a selected Puppet pin: the same parameter of every other selected pin, on + // this layer too, so several pins move or key together (#1751). + let pin = super::puppet::layer_pins(layer).find(|g| g.props().any(|q| q.uid == uid)).map(|g| g.uid); + if let Some(pin) = pin + && s.state.selected_props.contains(&(lid, pin)) + { + for (ol, ou) in &s.state.selected_props { + let Some(other) = comp.layer(*ol).filter(|l| !l.switches.locked) else { continue }; + let Some(q) = super::puppet::layer_pins(other).find(|g| g.uid == *ou).and_then(|g| g.get(&pr.match_id)).filter(same_kind) else { continue }; + if (*ol, q.uid) != (lid, uid) && !twins.contains(&(*ol, q.uid)) { + twins.push((*ol, q.uid)); + } + } + } + Ok(twins) } /// `p` aimed at property `uid` of layer `lid` instead. From 13821ab69485f532989b5e5c98825c83719c19f2 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 17:03:20 -0400 Subject: [PATCH 16/60] Puppet: test editing several selected pins at once (#1751) --- crates/engine/src/tests/paint.rs | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/engine/src/tests/paint.rs b/crates/engine/src/tests/paint.rs index db831ca96..3659ce0c4 100644 --- a/crates/engine/src/tests/paint.rs +++ b/crates/engine/src/tests/paint.rs @@ -308,6 +308,45 @@ fn puppet_select_all_takes_the_pin_kind_and_several_pins_record_together() { assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [st], "samples": samples})).is_err()); } +/// Editing a parameter of one of several selected pins edits the same parameter of the others, +/// in one undo step; unselected pins keep theirs (#1751). +#[test] +fn editing_one_selected_pin_edits_the_other_selected_pins() { + let (mut s, id) = setup(); + let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": id, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap(); + let (a, b, c) = (pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90)); + let position = |s: &Session, pin: u64| layer(s, id).props.find_group(pin).unwrap().get("position").unwrap().clone(); + let (pa, pb) = (position(&s, a).uid, position(&s, b).uid); + let at = |s: &Session, pin: u64| position(s, pin).value_at(Tick::ZERO).components(); + s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap(); + s.execute("prop.select", json!({"layer": id, "prop": b, "selectKeys": false, "add": true})).unwrap(); + + // A typed value lands on both selected pins, and one Undo takes it back. + let steps = s.history.undo.len(); + s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [20, 40], "selected": true})).unwrap(); + assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![20.0, 40.0], vec![20.0, 40.0], vec![90.0, 30.0])); + assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); + s.undo(); + assert_eq!((at(&s, a), at(&s, b)), (vec![10.0, 30.0], vec![50.0, 30.0])); + + // A scrub moves each selected pin by the same change from its own value. + s.execute("prop.set", json!({"layer": id, "prop": pb, "value": [55, 25], "selected": true, "offset": true})).unwrap(); + assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![15.0, 25.0], vec![55.0, 25.0], vec![90.0, 30.0])); + + // The stopwatch follows on both (a new pin starts keyed, so the toggle turns it off). + let animated = |s: &Session| [a, b, c].map(|p| position(s, p).is_animated()); + assert_eq!(animated(&s), [true; 3]); + s.execute("prop.toggleAnimation", json!({"layer": id, "prop": pa, "selected": true})).unwrap(); + assert_eq!(animated(&s), [false, false, true]); + + // Without `selected`, or with only one pin selected, the edit stays on its own pin. + s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [0, 0]})).unwrap(); + assert_eq!(at(&s, b), vec![55.0, 25.0]); + s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap(); + s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [5, 5], "selected": true})).unwrap(); + assert_eq!(at(&s, b), vec![55.0, 25.0]); +} + fn expr_of(s: &Session, layer: u64, uid: u64) -> String { let l = layer_of(s, layer); l.props.find(uid).and_then(|p| p.expr.as_ref()).map(|e| e.text.clone()).unwrap_or_default() From b86bc1d2a9c60dd3a4f78c117b41ec1a1723daba Mon Sep 17 00:00:00 2001 From: Anubhav Rai Date: Tue, 27 Oct 2026 23:24:13 +0530 Subject: [PATCH 17/60] automation: Create Proxy queues an output the same way (#2479) `file.createProxy` builds a Render Queue item exactly as `renderQueue.add` does, with `[compName]_proxy.[fileExtension]` as its output, so an unsaved project sent it to the home folder too. Its `path` / `output` had to join `WRITES` for this: the confinement only knows an output was named through that table, and it was reading them as a read before, so a proxy could be written into a read-only root (part of the F-11 audit in #2131). Co-Authored-By: Claude Opus 5 --- crates/automation/src/confine.rs | 3 ++- crates/automation/src/tests.rs | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index af8a69089..b646ba7ba 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -86,13 +86,14 @@ const WRITES: &[(&str, &[&str])] = &[ ("essential.exportTemplate", &["path"]), ("shortcuts.export", &["path"]), ("renderQueue.add", &["output", "path"]), + ("file.createProxy", &["path", "output"]), ]; /// Commands that write where they are not told: the project's file, the Render Queue's outputs. const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"]; /// Commands that queue a render, whose output defaults to the project's folder when not given. -const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender"]; +const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender", "file.createProxy"]; /// What must be looked up before an implicitly writing command may run. #[derive(Debug, PartialEq, Eq)] diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index e3282dc71..55cc3b3cd 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1396,7 +1396,7 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { s.backend().confine(Confinement::new(&[], std::slice::from_ref(&write)).unwrap()); call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}})); // Never saved: neither way of queueing a render may fall back to the home folder. - for command in ["renderQueue.add", "render.preRender"] { + for command in ["renderQueue.add", "render.preRender", "file.createProxy"] { let (c, err) = call(&mut s, "execute_command", json!({"command": command, "params": {"comp": "A"}})); assert!(err, "`{command}` without an output should be refused: {c:?}"); let msg = c[0]["text"].as_str().unwrap_or_default(); @@ -1406,6 +1406,10 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}"); // Naming an output inside a write root is how it is done. call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}})); + // A proxy's own `path` is a write, not the read it used to be checked as (#2131 F-11). + let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "/elsewhere/proxy.mov"}})); + assert!(err, "a proxy outside the roots should be refused: {c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("cannot write"), "{c:?}"); // Once the project is saved there, the default output (beside it) is queued as before. call_json(&mut s, "save_project", json!({"path": p("proj.ecproj")})); call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}})); From 9cc232096c184e5e58bd0571486b6e5961c98923 Mon Sep 17 00:00:00 2001 From: Anubhav Rai Date: Tue, 27 Oct 2026 23:39:21 +0530 Subject: [PATCH 18/60] automation: an output module template is an output nobody named (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render.preRender` takes an Output Module template where a path goes, and the confinement skips the path check for one. That made `given` true, so the new lookup was skipped — and the template's own output is relative, so it resolved exactly like an output the agent never gave: `render.preRender {output: "Lossless"}` on an unsaved project queued `~/Movies/Lossless.mov` and `~/Movies/Lossless_RenderLog.txt`. The lookup now asks whether a *file* was named, so a template name takes the same route as no output at all. Found by CodeRabbit on the pull request. Co-Authored-By: Claude Opus 5 --- crates/automation/src/confine.rs | 6 +++++- crates/automation/src/tests.rs | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index b646ba7ba..d9807ca40 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -169,6 +169,9 @@ impl Confinement { (None, None) => (&[][..], Access::Read), }; let mut given = false; + // Whether one of them is a file and not a Render Queue template, whose own output is + // relative and resolved like one the agent never gave. + let mut gave_a_file = false; for key in named { let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue }; given = true; @@ -181,6 +184,7 @@ impl Confinement { match p.as_str() { Some(s) if id == "render.preRender" && !looks_like_path(s) => {} Some(s) => { + gave_a_file = true; self.check(s, access)?; } None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), @@ -203,7 +207,7 @@ impl Confinement { // home folder when the project has never been saved, outside every root (#2479). Only // that last case is certainly outside, and reading the preference here would duplicate // the engine's precedence, so an unsaved project is told to name its output instead. - if QUEUES_AN_OUTPUT.contains(&id) && !given { + if QUEUES_AN_OUTPUT.contains(&id) && !gave_a_file { lookups.push(Lookup::DefaultOutput); } if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 55cc3b3cd..3c5620fbf 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1402,6 +1402,10 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { let msg = c[0]["text"].as_str().unwrap_or_default(); assert!(msg.contains("the project has no file yet"), "`{command}`: {msg}"); } + // Naming an Output Module template instead of a file is the same: its own output is relative. + let (c, err) = call(&mut s, "execute_command", json!({"command": "render.preRender", "params": {"comp": "A", "output": "Lossless"}})); + assert!(err, "a template output should be refused too: {c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("the project has no file yet"), "{c:?}"); let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}"); // Naming an output inside a write root is how it is done. From 80a33f0e86271a2f1f363374eccabe11388cf61f Mon Sep 17 00:00:00 2001 From: Anubhav Rai Date: Wed, 28 Oct 2026 00:03:47 +0530 Subject: [PATCH 19/60] automation: an array where the output goes is no output either (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file.createProxy` and `renderQueue.add` read a single string (`str_p`), so `{path: ["…/a.mov"]}` leaves them on their default while the confinement saw a path it could check and let the queue go ahead: on an unsaved project that was `~/Movies/A_proxy.mov` again. Only a plain string now counts as an output the agent named. Found by CodeRabbit on the pull request, which reported it fixed by 9cc23209; it was not, and the case is in the test now. Co-Authored-By: Claude Opus 5 --- crates/automation/src/confine.rs | 7 ++++--- crates/automation/src/tests.rs | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index d9807ca40..ce6717f30 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -169,8 +169,9 @@ impl Confinement { (None, None) => (&[][..], Access::Read), }; let mut given = false; - // Whether one of them is a file and not a Render Queue template, whose own output is - // relative and resolved like one the agent never gave. + // Whether one of them is a file: not a Render Queue template, whose own output is relative + // and resolved like one the agent never gave, and not an array, which the commands that + // take a single output read as nothing at all. let mut gave_a_file = false; for key in named { let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue }; @@ -184,7 +185,7 @@ impl Confinement { match p.as_str() { Some(s) if id == "render.preRender" && !looks_like_path(s) => {} Some(s) => { - gave_a_file = true; + gave_a_file |= v.is_string(); self.check(s, access)?; } None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 3c5620fbf..61a726d6b 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1402,6 +1402,10 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { let msg = c[0]["text"].as_str().unwrap_or_default(); assert!(msg.contains("the project has no file yet"), "`{command}`: {msg}"); } + // An array where a path goes: the engine reads a scalar, so this is no output either. + let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": [p("arr.mov")]}})); + assert!(err, "an array where the output goes should be refused: {c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("the project has no file yet"), "{c:?}"); // Naming an Output Module template instead of a file is the same: its own output is relative. let (c, err) = call(&mut s, "execute_command", json!({"command": "render.preRender", "params": {"comp": "A", "output": "Lossless"}})); assert!(err, "a template output should be refused too: {c:?}"); From 0e49df4f738c2cb19faa5d24639ffaece9437f35 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 17:40:15 -0400 Subject: [PATCH 20/60] Timeline: a property's chain link can start off (#2485) --- crates/ui-egui/src/state.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3a96d3c50..ed642fd62 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -307,6 +307,21 @@ impl Default for ViewerState { } impl TimelineState { + /// Whether property `uid`'s chain link (Constrain Proportions) is on. One that starts linked + /// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (Audio Levels) + /// records it in `linked`. + pub fn is_linked(&self, uid: u64, linked_by_default: bool) -> bool { + if linked_by_default { !self.unlinked.contains(&uid) } else { self.linked.contains(&uid) } + } + + /// Turn property `uid`'s chain link on or off (see [`Self::is_linked`]). + pub fn toggle_link(&mut self, uid: u64, linked_by_default: bool) { + let set = if linked_by_default { &mut self.unlinked } else { &mut self.linked }; + if !set.remove(&uid) { + set.insert(uid); + } + } + /// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last /// reveal. Other layers keep theirs. pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { @@ -414,6 +429,9 @@ pub struct TimelineState { /// was turned off. #[serde(default)] pub unlinked: BTreeSet, + /// Audio Levels properties whose channel link (off by default) was turned on (#2485). + #[serde(default)] + pub linked: BTreeSet, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -466,6 +484,7 @@ impl Default for TimelineState { expr_closed: BTreeSet::new(), expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), + linked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, scroll_to: None, From 2702ca18d50096c29440c21a2adf1c855b717802 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 17:40:15 -0400 Subject: [PATCH 21/60] Audio: linked Audio Levels move both channels by the same dB (#2485) --- crates/ui-egui/src/panels/timeline.rs | 18 +++++++++++------- crates/ui-egui/src/widgets.rs | 7 +++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a45d8e708..c8bf30580 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -5494,16 +5494,19 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> // Mask Feather: two linked values, never negative (as in After Effects, #203). let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. })); let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) }; + // Audio Levels link their two channels too, off by default, by the same dB change (#2485). + let levels = prop.match_id == "levels" && layer.props.parent_of(uid).is_some_and(|g| g.match_id == "audio"); // Scale and Mask Feather: the chain link (Constrain Proportions, on by default). - let linkable = feather || (pct && prop.match_id == "scale"); - let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + let linkable = feather || levels || (pct && prop.match_id == "scale"); + let linked = linkable && app.ui.timeline.is_linked(uid, !levels); if linkable { let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0)); - let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions")); + let label = if levels { "Link Channels" } else { "Constrain Proportions" }; + let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr(label)); icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); - app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions"); - if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { - app.ui.timeline.unlinked.insert(uid); + app.auto.add(&format!("timeline.prop.{uid}.link"), lr, label); + if resp.clicked() { + app.ui.timeline.toggle_link(uid, !levels); } x += 18.0; } @@ -5524,7 +5527,8 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> // Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub). let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 }; let from = if shown == 1 { d } else { 0 }; - for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + let link = if levels { widgets::shifted } else { widgets::constrained }; + for (e, v) in link(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { if let Some(slot) = nc.get_mut(from + e) { *slot = v; } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 58383fe43..0f0cbc413 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -820,6 +820,13 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { .collect() } +/// Linked values after component `d` of `c` became `v`: the others move by the same amount, so +/// linked Audio Levels keep their stereo balance in dB (#2485). +pub(crate) fn shifted(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter().enumerate().map(|(e, &x)| if e == d { v } else { x + (v - old) }).collect() +} + #[cfg(test)] mod tests { use super::*; From 3f71915fc07bb223bdcc36436d66619fefdd60ec Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 17:40:15 -0400 Subject: [PATCH 22/60] Tests: linked Audio Levels keep their stereo balance (#2485) --- crates/ui-egui/tests/it/main.rs | 1 + .../tests/it/ui_timeline_audio_levels.rs | 107 ++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 crates/ui-egui/tests/it/ui_timeline_audio_levels.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6b4c08912..8573f83b2 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -68,6 +68,7 @@ mod ui_text_edit; mod ui_text_range_handles; mod ui_theme_favorites; mod ui_time_zoom_controls; +mod ui_timeline_audio_levels; mod ui_timeline_effect_label; mod ui_timeline_pan; mod ui_timeline_shapes; diff --git a/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs new file mode 100644 index 000000000..e1c626489 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs @@ -0,0 +1,107 @@ +//! Audio Levels' channel link in the Timeline (#2485): off by default; linked, an edit to one +//! channel moves the other by the same dB, so the stereo balance stays, typed or scrubbed, on a +//! static or animated property, and one Undo takes it back. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +/// Press on `from`, move to `to` in `steps`, release there. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=steps { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + drag(h, p, p, 1); +} + +fn type_value(h: &mut Harness<'_, EffectcraftApp>, id: &str, v: &str) { + click(h, id); + h.input_mut().events.push(Event::Text(v.into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn linked_audio_levels_move_both_channels_by_the_same_db() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let l = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); + let uid = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().uid; + s.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["props".into()]); + h.state_mut().ui.timeline.reveal_props.insert(uid); + h.run_steps(3); + let levels = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().clone(); + let set = |h: &mut Harness<'_, EffectcraftApp>, v: [f64; 2]| { + h.state_mut().session.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": v})).unwrap(); + h.run_steps(2); + }; + let undo = |h: &mut Harness<'_, EffectcraftApp>| { + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + }; + let (left, right, link) = (format!("timeline.prop.{uid}.value.0"), format!("timeline.prop.{uid}.value.1"), format!("timeline.prop.{uid}.link")); + assert_eq!(h.state().auto.find(&link).unwrap().label, "Link Channels"); + + type_value(&mut h, &left, "-3"); + assert_eq!(levels(&h).value.components(), vec![-3.0, -12.0], "unlinked by default, one channel moves alone"); + set(&mut h, [-6.0, -12.0]); + + click(&mut h, &link); + type_value(&mut h, &left, "-3"); + assert_eq!(levels(&h).value.components(), vec![-3.0, -9.0], "linked, both rise 3 dB and keep their 6 dB balance"); + undo(&mut h); + assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back both channels"); + + type_value(&mut h, &right, "0"); + assert_eq!(levels(&h).value.components(), vec![6.0, 0.0], "the right channel leads just the same"); + set(&mut h, [0.0, 0.0]); + type_value(&mut h, &left, "-6"); + assert_eq!(levels(&h).value.components(), vec![-6.0, -6.0], "equal channels at 0 dB stay equal"); + + set(&mut h, [-6.0, -12.0]); + let from = center(&h, &left); + drag(&mut h, from, from + vec2(30.0, 0.0), 6); + let c = levels(&h).value.components(); + assert!(c[0] > -6.0, "the scrub raised the left channel: {c:?}"); + assert!((c[0] - c[1] - 6.0).abs() < 1e-9, "a linked scrub keeps the balance: {c:?}"); + undo(&mut h); + assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back the whole scrub"); + + // Animated: the edit at the current time changes that key's two channels together. + h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 0.0, "value": [-6.0, -12.0]})).unwrap(); + h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 1.0, "value": [0.0, 0.0]})).unwrap(); + h.run_steps(2); + type_value(&mut h, &left, "-10"); + let keys: Vec> = levels(&h).keys.iter().map(|k| k.value.components()).collect(); + assert_eq!(keys, vec![vec![-10.0, -16.0], vec![0.0, 0.0]], "the key at the current time moves both channels"); + + click(&mut h, &link); + type_value(&mut h, &left, "-4"); + let keys: Vec> = levels(&h).keys.iter().map(|k| k.value.components()).collect(); + assert_eq!(keys[0], vec![-4.0, -16.0], "unlinked again, the right channel stays"); +} From a90b58a8850103c9bb8318a4fb22599b3327f0c8 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 19:54:09 -0400 Subject: [PATCH 23/60] Engine: a typed prop.set can move follow components by each layer's own change (#2485) --- crates/engine/src/cmd/prop.rs | 14 ++++++++++++-- crates/engine/src/tests/timeline.rs | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 00232967e..17dee1898 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -300,7 +300,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 { /// `prop.set`; with `selected`, also on the other selected layers: the same value, or with /// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims` /// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers, -/// which keep the rest of their own value (#1434). With `offset` and +/// which keep the rest of their own value (#1434); `follow` components move on each layer by the +/// change its first typed component takes (linked Audio Levels keep each layer's balance, #2485). +/// With `offset` and /// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change /// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`, /// one that a continuing scrub keeps folding into). @@ -325,10 +327,18 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { (None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()), _ => None, }; + let follow: Vec = + p.get("follow").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect(); let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| { if let (Some(dims), Some(n)) = (&dims, &new) && let Some(mut c) = current_components(s, cid, olid, ouid) { + let change = dims.first().and_then(|&d| Some(n.get(d)? - c.get(d)?)).unwrap_or(0.0); + for &d in &follow { + if let Some(slot) = c.get_mut(d) { + *slot += change; + } + } for &d in dims { if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) { *slot = *v; @@ -1310,7 +1320,7 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value), follow? (with dims: components that move on each other layer by the change its first typed component takes)}", has_layers, set_selected ), diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 33dac7795..fa9117e37 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -1430,3 +1430,23 @@ fn a_typed_component_applies_only_that_component_to_the_selected_layers() { s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap(); assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]); } + +/// #2485: linked Audio Levels typed with several layers selected: each takes the typed Left, and +/// its Right moves by its own change, so every layer keeps its own stereo balance. +#[test] +fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let a = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "audio/levels", "value": [-20.0, -30.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let uid = prop(&s, a, "audio/levels").uid; + let undo = s.history.undo.len(); + s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [-3.0, -9.0], "selected": true, "dims": [0], "follow": [1]})).unwrap(); + assert_eq!(prop(&s, a, "audio/levels").value.components(), vec![-3.0, -9.0]); + assert_eq!(prop(&s, b, "audio/levels").value.components(), vec![-3.0, -13.0], "B rises 17 dB on both channels"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); +} From e26dc46b61806efbe46fc8c1702994dca8c62e63 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 19:54:09 -0400 Subject: [PATCH 24/60] Audio: linked typing keeps each selected layer's own stereo balance (#2485) --- crates/ui-egui/src/panels/timeline.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index c8bf30580..b052d32d2 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -5536,8 +5536,15 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> ve.set(actions, json!(nc)); // Typed into these components only: other selected layers keep the rest of // their own value (#1434). + // Linked Audio Levels: the typed channel, the other following by that layer's own + // change, so each keeps its balance (#2485). if let Some((_, a)) = actions.last_mut() { - a["dims"] = json!((from..from + shown).collect::>()); + if levels { + a["dims"] = json!([d]); + a["follow"] = json!((from..from + shown).filter(|&e| e != d).collect::>()); + } else { + a["dims"] = json!((from..from + shown).collect::>()); + } } } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; From 2314e8923f45b6d5d7c01c803e32dadd13771131 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 19:54:09 -0400 Subject: [PATCH 25/60] Timeline: chain links don't carry into a new or opened project (#2485) --- crates/ui-egui/src/app/frame.rs | 1 + crates/ui-egui/src/state.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index c0f9c7bd1..98bcfb45f 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -286,6 +286,7 @@ impl EffectcraftApp { /// `eframe::App::ui` and by tests with the root `Ui`. pub fn draw(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) { panels::viewer_text::begin_ui(ui.ctx()); + self.ui.timeline.scope_links(self.session.script_ui.project_epoch); self.draw_inner(ui, frame); // All exits, including fonts/device loss, observe this pass's real custom availability. panels::viewer_text::finish_ime(self, ui); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ed642fd62..d10676a5c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -322,6 +322,16 @@ impl TimelineState { } } + /// Forget the chain links when the project was replaced (new or opened): they are keyed by + /// property uid, which the next project reuses, so a link turned on must not carry into it. + pub fn scope_links(&mut self, project_epoch: u64) { + if self.link_epoch != project_epoch { + self.link_epoch = project_epoch; + self.unlinked.clear(); + self.linked.clear(); + } + } + /// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last /// reveal. Other layers keep theirs. pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { @@ -432,6 +442,9 @@ pub struct TimelineState { /// Audio Levels properties whose channel link (off by default) was turned on (#2485). #[serde(default)] pub linked: BTreeSet, + /// The project (its epoch) `unlinked` and `linked` belong to. + #[serde(skip)] + pub link_epoch: u64, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -485,6 +498,7 @@ impl Default for TimelineState { expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), linked: BTreeSet::new(), + link_epoch: 0, columns: default_tl_columns(), source_name: false, scroll_to: None, From fca40323b3cfc7a15f77e279bfa9f048f128ea87 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 19:54:10 -0400 Subject: [PATCH 26/60] Tests: linked Audio Levels across selected layers and a new project (#2485) --- .../tests/it/ui_timeline_audio_levels.rs | 73 +++++++++++++++++-- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs index e1c626489..bc0b9a7f9 100644 --- a/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs +++ b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs @@ -41,19 +41,39 @@ fn type_value(h: &mut Harness<'_, EffectcraftApp>, id: &str, v: &str) { h.run_steps(3); } -#[test] -fn linked_audio_levels_move_both_channels_by_the_same_db() { - let mut s = Session::default(); +/// A precomp layer (which has Audio Levels) in comp Main at `levels`; its layer id and the +/// property's uid. +fn add_sound_layer(s: &mut Session, levels: [f64; 2]) -> (u64, u64) { + let l = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": levels})).unwrap(); + (l, s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().uid) +} + +/// Comps Sound and Main. +fn comps(s: &mut Session) { s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap(); s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); - let l = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); - let uid = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().uid; - s.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap(); - let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); +} + +/// Twirl layer `l` open to its property `uid`. +fn reveal(h: &mut Harness<'_, EffectcraftApp>, l: u64, uid: u64) { h.state_mut().ui.timeline.open_layers.insert(l); h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["props".into()]); h.state_mut().ui.timeline.reveal_props.insert(uid); h.run_steps(3); +} + +fn levels_of(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().value.components() +} + +#[test] +fn linked_audio_levels_move_both_channels_by_the_same_db() { + let mut s = Session::default(); + comps(&mut s); + let (l, uid) = add_sound_layer(&mut s, [-6.0, -12.0]); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + reveal(&mut h, l, uid); let levels = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().clone(); let set = |h: &mut Harness<'_, EffectcraftApp>, v: [f64; 2]| { @@ -105,3 +125,42 @@ fn linked_audio_levels_move_both_channels_by_the_same_db() { let keys: Vec> = levels(&h).keys.iter().map(|k| k.value.components()).collect(); assert_eq!(keys[0], vec![-4.0, -16.0], "unlinked again, the right channel stays"); } + +/// With several layers selected, a linked typed Left sets each layer's Left and moves its Right +/// by that layer's own change: every layer keeps its own balance. +#[test] +fn linked_typing_keeps_each_selected_layers_own_balance() { + let mut s = Session::default(); + comps(&mut s); + let (a, uid) = add_sound_layer(&mut s, [-6.0, -12.0]); + let (b, other) = add_sound_layer(&mut s, [-20.0, -30.0]); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + reveal(&mut h, a, uid); + reveal(&mut h, b, other); + click(&mut h, &format!("timeline.prop.{uid}.link")); + type_value(&mut h, &format!("timeline.prop.{uid}.value.0"), "-3"); + assert_eq!(levels_of(&h, a), vec![-3.0, -9.0]); + assert_eq!(levels_of(&h, b), vec![-3.0, -13.0], "B rises 17 dB on both channels, keeping its 10 dB balance"); +} + +/// A link turned on does not carry into a new project whose Audio Levels reuse the property uid. +#[test] +fn a_new_project_starts_with_its_audio_levels_unlinked() { + let mut s = Session::default(); + comps(&mut s); + let (l, uid) = add_sound_layer(&mut s, [0.0, 0.0]); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + reveal(&mut h, l, uid); + click(&mut h, &format!("timeline.prop.{uid}.link")); + assert!(h.state().ui.timeline.is_linked(uid, false)); + + h.state_mut().session.execute("file.newProject", json!({})).unwrap(); + comps(&mut h.state_mut().session); + let (l, again) = add_sound_layer(&mut h.state_mut().session, [0.0, 0.0]); + assert_eq!(again, uid, "the new project reuses the property uid"); + reveal(&mut h, l, again); + assert!(!h.state().ui.timeline.is_linked(again, false), "the new project's levels start unlinked"); + type_value(&mut h, &format!("timeline.prop.{again}.value.0"), "-3"); + assert_eq!(levels_of(&h, l), vec![-3.0, 0.0]); +} From b9808d5a24ffab97b3693e672fb4084723177053 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 20:28:10 -0400 Subject: [PATCH 27/60] Engine: a typed prop.set measures each layer's follow change where it keys (#2485) --- crates/engine/src/cmd/prop.rs | 10 ++++++++-- crates/engine/src/tests/timeline.rs | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 17dee1898..3a8419765 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -267,8 +267,13 @@ fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value { /// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate /// Dimensions: its X / Y / Z Position). fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option> { + components_at(s, cid, lid, uid, None) +} + +/// [`current_components`] at layer time `at` (the CTI when `None`). +fn components_at(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, at: Option) -> Option> { let l = s.project.comp(cid)?.layer(lid)?; - let lt = l.layer_time(s.time_of(cid)); + let lt = at.unwrap_or_else(|| l.layer_time(s.time_of(cid))); if let Some(tr) = l.transform() && tr.get("position").is_some_and(|q| q.uid == uid) && tr.get("positionX").is_some() @@ -329,9 +334,10 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { }; let follow: Vec = p.get("follow").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect(); + // Each layer's own value where `set` writes it (a `time` other than the CTI keys there). let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| { if let (Some(dims), Some(n)) = (&dims, &new) - && let Some(mut c) = current_components(s, cid, olid, ouid) + && let Some(mut c) = components_at(s, cid, olid, ouid, snapped_key_time(s, cid, olid, q, "prop.set").ok().flatten()) { let change = dims.first().and_then(|&d| Some(n.get(d)? - c.get(d)?)).unwrap_or(0.0); for &d in &follow { diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index fa9117e37..75d1b71be 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -1449,4 +1449,10 @@ fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() { assert_eq!(prop(&s, a, "audio/levels").value.components(), vec![-3.0, -9.0]); assert_eq!(prop(&s, b, "audio/levels").value.components(), vec![-3.0, -13.0], "B rises 17 dB on both channels"); assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + + // Keyed at a time other than the CTI: B's change is measured on its key there. + animate(&mut s, b, "audio/levels", &[(0.0, json!([-20.0, -30.0])), (1.0, json!([-10.0, -40.0]))]); + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [-4.0, -10.0], "time": 1.0, "selected": true, "dims": [0], "follow": [1]})).unwrap(); + let keys: Vec> = prop(&s, b, "audio/levels").keys.iter().map(|k| k.value.components()).collect(); + assert_eq!(keys, vec![vec![-20.0, -30.0], vec![-4.0, -34.0]], "B's 1 s key rises 6 dB on both channels"); } From b758d7173e2eb24ca8f80ca8af52e2c0e61bc009 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 21:24:20 -0400 Subject: [PATCH 28/60] Timeline: chain links are rescoped after a shortcut or menu replaces the project (#2485) --- crates/ui-egui/src/app/frame.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 98bcfb45f..534d3863d 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -286,7 +286,6 @@ impl EffectcraftApp { /// `eframe::App::ui` and by tests with the root `Ui`. pub fn draw(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) { panels::viewer_text::begin_ui(ui.ctx()); - self.ui.timeline.scope_links(self.session.script_ui.project_epoch); self.draw_inner(ui, frame); // All exits, including fonts/device loss, observe this pass's real custom availability. panels::viewer_text::finish_ime(self, ui); @@ -394,6 +393,8 @@ impl EffectcraftApp { let header = egui::Rect::from_min_size(egui::pos2(full.min.x, top), egui::vec2(full.width(), header_h)); header::show(self, ui, header); let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0)); + // After the shortcuts, menus and header, which can replace the project (New, Open). + self.ui.timeline.scope_links(self.session.script_ui.project_epoch); self.dock_area(ui, body); #[cfg(not(target_arch = "wasm32"))] prefs_live::dropped_files(self, ui, full); From 7df52fb20ec010adc459e72d12a900ac02bf88e2 Mon Sep 17 00:00:00 2001 From: Anubhav-Rai Date: Thu, 29 Oct 2026 10:09:15 +0530 Subject: [PATCH 29/60] automation: a proxy's relative name is a template, not a path (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked for on the PR: #1347 allows `file.createProxy` a name that is not a path, because the engine reads it as an Output Module template, and the `WRITES` entry added here refused exactly that. Dropped, so the classification is #1347's alone. Nothing is opened by it. A proxy named outside the roots is still refused — `cannot read` now rather than `cannot write`, which is #1347's to settle — so the test asserts the refusal and no longer its wording. A relative name is now accepted and resolves beside the saved project, inside the write root, which the test pins so this does not quietly come back. The lookup this PR is for is untouched: `file.createProxy` stays in `QUEUES_AN_OUTPUT`, so a proxy queued from a project with no file is still refused rather than landing in the home folder. Co-Authored-By: Claude Opus 5 --- crates/automation/src/confine.rs | 1 - crates/automation/src/tests.rs | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index ce6717f30..3a4d462e4 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -86,7 +86,6 @@ const WRITES: &[(&str, &[&str])] = &[ ("essential.exportTemplate", &["path"]), ("shortcuts.export", &["path"]), ("renderQueue.add", &["output", "path"]), - ("file.createProxy", &["path", "output"]), ]; /// Commands that write where they are not told: the project's file, the Render Queue's outputs. diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 61a726d6b..088bca335 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1414,12 +1414,16 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}"); // Naming an output inside a write root is how it is done. call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}})); - // A proxy's own `path` is a write, not the read it used to be checked as (#2131 F-11). + // A proxy named outside the roots is refused. Whether that reads as a write or a read is + // #1347's to classify, so this asserts the refusal and not its wording. let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "/elsewhere/proxy.mov"}})); assert!(err, "a proxy outside the roots should be refused: {c:?}"); - assert!(c[0]["text"].as_str().unwrap_or_default().contains("cannot write"), "{c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("outside the automation roots"), "{c:?}"); // Once the project is saved there, the default output (beside it) is queued as before. call_json(&mut s, "save_project", json!({"path": p("proj.ecproj")})); + // A name that is not a path is an Output Module template, and the output it resolves to lands + // beside the saved project: inside the write root, so it is allowed (#1347 relies on this). + call_json(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "ProxyTemplate"}})); call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}})); let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); let out = queue["items"][1]["outputPath"].as_str().unwrap_or_default(); From 56821949e30f3accbbd198755554a08fdf0ee6d2 Mon Sep 17 00:00:00 2001 From: Anubhav-Rai Date: Thu, 29 Oct 2026 10:30:13 +0530 Subject: [PATCH 30/60] automation: the out-of-root proxy path is absolute on Windows too (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced by the commit before this one. Dropping the `WRITES` entry changed that refusal's wording from `cannot write` to `cannot read`, so the assertion moved to the part both share — `outside the automation roots` — but `/elsewhere/proxy.mov` is not absolute on Windows, where it is refused for that instead and the assertion fails. The gate runs ubuntu only, so it stayed green. A sibling of the write root under the temp directory is absolute on every platform, so the refusal is the one the test is about. Co-Authored-By: Claude Opus 5 --- crates/automation/src/tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 088bca335..e2acce85f 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1415,8 +1415,11 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { // Naming an output inside a write root is how it is done. call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}})); // A proxy named outside the roots is refused. Whether that reads as a write or a read is - // #1347's to classify, so this asserts the refusal and not its wording. - let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "/elsewhere/proxy.mov"}})); + // #1347's to classify, so this asserts the refusal and not its wording. The path is a sibling + // of the write root rather than `/elsewhere/…`, which is not absolute on Windows and would be + // refused for that instead. + let outside = base.join("elsewhere").join("proxy.mov").to_string_lossy().into_owned(); + let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": outside}})); assert!(err, "a proxy outside the roots should be refused: {c:?}"); assert!(c[0]["text"].as_str().unwrap_or_default().contains("outside the automation roots"), "{c:?}"); // Once the project is saved there, the default output (beside it) is queued as before. From c074612acc61bd487ceb734c56afea8359c905e7 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Thu, 29 Oct 2026 18:53:45 +0800 Subject: [PATCH 31/60] Text: duplicate during a text edit copies the edited layer even after another is selected (#2226) --- crates/engine/src/cmd.rs | 4 ++-- crates/engine/src/cmd/edit.rs | 16 ++++++++++++--- crates/engine/src/cmd/params.rs | 7 ++++++- crates/engine/src/tests/text_edit.rs | 29 ++++++++++++++++++++++++++++ crates/engine/src/tests/three_d.rs | 1 + 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index cba6df6ef..eb308011d 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -163,8 +163,8 @@ pub use enabled::always; pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run}; pub use params::match_path_of; pub(crate) use params::{ - b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, resolve_effect_path, resolve_layer, selectable, selected_leaf_props, str_p, - time_p, unlocked, visible_p, + b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_or, layers_p, merge_p, resolve_effect_path, resolve_layer, selectable, selected_leaf_props, + str_p, time_p, unlocked, visible_p, }; pub use schema::{accepted_params, check_params, params_schema}; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f89974c80..758a2a4a0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -6,7 +6,7 @@ use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, La use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p}; +use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, selected_leaf_props, str_p}; use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd}; fn can_undo(s: &Session) -> std::result::Result<(), String> { @@ -104,6 +104,10 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) { *next += 1; } +/// `edit.duplicate`: copies the targets in place. Without `layers` the targets are, in order: +/// during a text edit the edited layer (the edit is committed first, as `text.endEdit` does), +/// else the selected effects, shape items or text animators, else the selected layers. Layer +/// copies are selected and their ids returned; the other paths return their delegated result. fn duplicate(s: &mut Session, p: &Value) -> Result { let editing = s.state.text_edit.is_some(); // Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). An open text edit @@ -132,9 +136,15 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { // A deselected layer never keeps an open text edit: the copies take the selection, so commit // an open edit first, exactly as `text.endEdit` (typed text kept, an empty Type-tool layer // discarded) and as Deselect All does — one call on the command path so every route (menu, - // shortcut, palette, MCP, script) ends the edit and leaves the same final state. + // shortcut, palette, MCP, script) ends the edit and leaves the same final state. Unnamed, the + // target is the edited layer even when an engine route selected another one meanwhile; it is + // read before the commit, which may discard an empty Type-tool layer and leave nothing to copy. + let edited = s.state.text_edit.as_ref().map(|e| e.layer); super::text_edit::end_edit(s, &Value::Null)?; - let (cid, ids) = layers_p(s, p)?; + let (cid, ids) = match edited { + Some(layer) => layers_or(s, p, &[layer])?, + None => layers_p(s, p)?, + }; let new = s.edit("Duplicate", None, |proj, st| { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 7c4e1c292..794d3484a 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -142,6 +142,11 @@ pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Laye /// `layers` param (array) or the selection. pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec)> { + layers_or(s, p, &s.state.selected_layers) +} + +/// `layers` param (array), else `layer`, else those of `fallback` still in the comp. +pub(crate) fn layers_or(s: &Session, p: &Value, fallback: &[LayerId]) -> Result<(ItemId, Vec)> { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; // Explicit references that match no layer are an error (agents otherwise get a silent no-op). @@ -153,7 +158,7 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec)> if let Some(v) = p.get("layer").filter(|v| !v.is_null()) { return Ok((cid, vec![resolve(v)?])); } - Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect())) + Ok((cid, fallback.iter().copied().filter(|l| comp.layer(*l).is_some()).collect())) } /// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 176fe2ae6..dfd2596cd 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1312,6 +1312,35 @@ fn duplicate_during_an_edit_leaves_no_key_or_vertex_selected_on_the_original() { } } +#[test] +fn duplicate_during_an_edit_copies_the_edited_layer_after_another_is_selected() { + use effectcraft_project::LayerId; + // An engine route (MCP, control channel, script) can select another layer while the edit + // stays open. The implicit Duplicate still means the edited layer, as Clear does; a named + // target keeps its meaning. + let (mut s, t) = session("Hello"); + let other = s.execute("layer.newText", json!({"text": "Other"})).unwrap()["layer"].as_u64().unwrap(); + begin_edit_at(&mut s, t, 5); + s.execute("layer.select", json!({"layer": other})).unwrap(); + assert!(s.state.text_edit.as_ref().is_some_and(|e| e.layer == LayerId(t)), "selecting another layer leaves the edit open"); + let before = layer_count(&s); + let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit"); + assert_eq!(layer_count(&s), before + 1, "one layer is copied"); + assert_eq!(doc(&s, copy).text, "Hello", "the edited layer is copied, not the newly selected one"); + assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and its copy is selected"); + // Null parameters name no layer, as everywhere else layers are read. + for unnamed in [json!({"layers": null}), json!({"layer": null})] { + begin_edit_at(&mut s, t, 5); + s.execute("layer.select", json!({"layer": other})).unwrap(); + let copy = s.execute("edit.duplicate", unnamed.clone()).unwrap()[0].as_u64().unwrap(); + assert_eq!(doc(&s, copy).text, "Hello", "{unnamed}: the edited layer is copied"); + } + begin_edit_at(&mut s, t, 5); + let named = s.execute("edit.duplicate", json!({"layer": other})).unwrap()[0].as_u64().unwrap(); + assert_eq!(doc(&s, named).text, "Other", "a named layer is copied as named"); +} + #[test] fn duplicate_ends_the_open_edit_and_selects_the_copy() { let (mut s, t) = editing("Hello", 5); diff --git a/crates/engine/src/tests/three_d.rs b/crates/engine/src/tests/three_d.rs index dc28ae42a..f10c74043 100644 --- a/crates/engine/src/tests/three_d.rs +++ b/crates/engine/src/tests/three_d.rs @@ -26,6 +26,7 @@ fn auto_orient_command() { assert!(!effectcraft_render::three_d::camera::is_two_node(&s.active_comp().unwrap().layers[0])); } +/// Layers in the active comp. pub(crate) fn layer_count(s: &Session) -> usize { s.active_comp().unwrap().layers.len() } From 54a141d0a07a6250a6bfa9573659058a0adf5a58 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Thu, 29 Oct 2026 19:20:46 +0800 Subject: [PATCH 32/60] Text: Duplicate stays enabled during a text edit with nothing selected (#2226) --- crates/engine/src/cmd/edit.rs | 7 ++++++- crates/engine/src/tests/text_edit.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 758a2a4a0..eca532039 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -989,7 +989,7 @@ pub fn specs() -> Vec { ["Edit"], Some("Cmd+D"), "{layers?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)", - has_layers, + layers_or_text_edit, duplicate ), cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split), @@ -1061,6 +1061,11 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> { if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) } } +/// Duplicate's unnamed target: the edited layer during a text edit, else the selection. +fn layers_or_text_edit(s: &Session) -> std::result::Result<(), String> { + if s.state.text_edit.is_some() { super::has_comp(s) } else { has_layers(s) } +} + fn always_ok(_: &Session) -> std::result::Result<(), String> { Ok(()) } diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index dfd2596cd..7a8598a72 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1341,6 +1341,25 @@ fn duplicate_during_an_edit_copies_the_edited_layer_after_another_is_selected() assert_eq!(doc(&s, named).text, "Other", "a named layer is copied as named"); } +#[test] +fn duplicate_during_an_edit_with_nothing_selected_copies_the_edited_layer() { + use effectcraft_project::LayerId; + // An engine route can also clear the selection while the edit stays open. Duplicate stays + // available, since its unnamed target is the edited layer, not the selection. + let (mut s, _) = editing("Hello", 5); + s.execute("layer.select", json!({"layers": []})).unwrap(); + assert!(s.state.selected_layers.is_empty() && s.state.text_edit.is_some(), "the edit is open with nothing selected"); + let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit"); + assert_eq!(doc(&s, copy).text, "Hello", "and copies the edited layer"); + assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "which is selected"); + // With no edit open, nothing selected still means nothing to duplicate. + s.execute("layer.select", json!({"layers": []})).unwrap(); + let before = layer_count(&s); + assert!(s.execute("edit.duplicate", json!({})).is_err(), "outside an edit Duplicate needs a selection"); + assert_eq!(layer_count(&s), before, "and copies nothing"); +} + #[test] fn duplicate_ends_the_open_edit_and_selects_the_copy() { let (mut s, t) = editing("Hello", 5); From f9d514bb217ba89b1c9d65336347821f6f3bffa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:29:10 +0000 Subject: [PATCH 33/60] #1284: fix the shape double-click's size and paint, focus after closing the queue, the Graph Editor key menu - The comp-sized shape is made by shape.newShape (new newLayer option) so it takes the Tools bar's Fill and Stroke, and a polygon or star fits the shorter comp side. - Cmd+W on the Render Queue moves the focus to a panel that is still open. - The Graph Editor's key menu is the Timeline's (one shared function): all its commands, labels in the interface language, edits disabled on a locked layer. - keys.delete and keys.set leave a locked layer's keys alone. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/engine/src/cmd/anim.rs | 3 + crates/engine/src/cmd/prop.rs | 6 +- crates/engine/src/cmd/shape_tool.rs | 6 +- crates/engine/src/tests/delete_keys.rs | 21 ++++ crates/ui-egui/src/header.rs | 61 +++++++++- crates/ui-egui/src/menus/invoke.rs | 4 + crates/ui-egui/src/menus/tests.rs | 4 + crates/ui-egui/src/panels/graph.rs | 44 +------ crates/ui-egui/src/panels/timeline.rs | 121 +++++++++++--------- crates/ui-egui/src/panels/timeline/tests.rs | 43 +++++++ 10 files changed, 214 insertions(+), 99 deletions(-) diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs index 325845115..76bfb5a63 100644 --- a/crates/engine/src/cmd/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -296,6 +296,9 @@ fn set_key(s: &mut Session, p: &Value) -> Result { let base = KeyTimeBase::from_params(p, "keys.set")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + if layer.switches.locked { + return Err(bad("keys.set", "the layer is locked")); + } let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?; let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?; let value = p.get("value").cloned(); diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 00232967e..36f76706f 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -1046,6 +1046,10 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut removed = 0usize; for k in &sel { + // A layer locked after its keys were selected keeps them (the selection stays too). + if comp.layer(k.layer).is_some_and(|l| l.switches.locked) { + continue; + } if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop)) && let Some(i) = key_at(&pr.keys, k.time) { @@ -1056,7 +1060,7 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result { removed += 1; } } - st.selected_keys.clear(); + st.selected_keys.retain(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)); // The keys actually removed: a selected key that is gone (or listed twice) isn't counted, // so a caller can tell that a delete changed nothing. Ok(json!(removed)) diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index e1f3607b1..3db347e60 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -318,7 +318,9 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { }; let pos = position_p(p, c)?.unwrap_or([comp.width as f64 / 2.0, comp.height as f64 / 2.0]); let paint = paint_p(p, &s.state.shape_tool, c)?; - let target = draw_target(s, &comp, p, c)?; + // `newLayer` makes a layer of its own even with a shape layer selected (a shape that fills the + // comp for a background or matte, whatever is selected). + let target = if b_p(p, "newLayer") == Some(true) { None } else { draw_target(s, &comp, p, c)? }; // In the target layer's space: the centre maps through the layer's transform, the size by // its scale. let (centre, size) = match target.and_then(|l| comp.layer(l)) { @@ -374,7 +376,7 @@ pub fn specs() -> Vec { "Shape Tool", [], None, - "{layer?, kind?: rect|rounded|ellipse|polygon|star, size? [w,h], position? [x,y] (the centre), space?: comp|layer, name? (a new layer's), fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions)} → {layer, group}", + "{layer?, newLayer? (a new shape layer even with a shape layer selected), kind?: rect|rounded|ellipse|polygon|star, size? [w,h], position? [x,y] (the centre), space?: comp|layer, name? (a new layer's), fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions)} → {layer, group}", has_comp, new_shape ), diff --git a/crates/engine/src/tests/delete_keys.rs b/crates/engine/src/tests/delete_keys.rs index b1b9af316..029104497 100644 --- a/crates/engine/src/tests/delete_keys.rs +++ b/crates/engine/src/tests/delete_keys.rs @@ -43,3 +43,24 @@ fn delete_keys_reports_zero_when_no_selected_key_exists() { assert_eq!(removed, json!(0)); assert_eq!(keys_of(&s, layer, prop), keys); } + +/// A layer locked after its keys were selected keeps them: Delete and Edit Value leave them alone. +#[test] +fn a_locked_layers_keys_cannot_be_deleted_or_edited() { + let (mut s, layer, prop) = setup(); + let keys = keys_of(&s, layer, prop); + s.state.selected_keys = keys.clone(); + s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap(); + let removed = s.execute_checked("keys.delete", json!({})).unwrap(); + assert_eq!(removed, json!(0)); + assert_eq!(keys_of(&s, layer, prop), keys); + assert_eq!(s.state.selected_keys, keys, "the selection stays"); + let e = s.execute("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap_err().to_string(); + assert!(e.contains("locked"), "{e}"); + let value = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys[0].value.as_f64(); + assert_eq!(value, 0.0); + // Unlocked again, both work. + s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap(); + s.execute_checked("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap(); + assert_eq!(s.execute_checked("keys.delete", json!({})).unwrap(), json!(3)); +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 45caa86de..8a336fcbb 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -57,6 +57,22 @@ fn brand_and_home(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter x + 6.0 } +/// The `shape.newShape` parameters for a shape that fills a `w` × `h` composition with `tool`: a new +/// layer, painted with the Tools bar's Fill and Stroke. A polygon or star is round, so it takes the +/// shorter side; the other kinds take the whole frame. +pub(crate) fn comp_sized_shape_params(tool: Tool, w: u32, h: u32) -> serde_json::Value { + let kind = crate::panels::viewer::shape_kind(tool); + let (w, h) = (f64::from(w), f64::from(h)); + let size = if matches!(kind, "polygon" | "star") { [w.min(h); 2] } else { [w, h] }; + json!({"kind": kind, "size": size, "position": [w / 2.0, h / 2.0], "newLayer": true}) +} + +/// Make the shape layer that fills the composition for a double-clicked shape `tool`. +fn comp_sized_shape(app: &mut EffectcraftApp, ctx: &egui::Context, tool: Tool) -> Result { + let (w, h) = app.session.active_comp().map(|c| (c.width, c.height)).ok_or("no composition is open")?; + crate::menus::invoke(app, ctx, "shape.newShape", comp_sized_shape_params(tool, w, h)) +} + /// The tool slots (with group separators after camera tools and pan-behind); returns the x after them. fn tool_slots(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { let t = app.tokens; @@ -114,8 +130,7 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: // backgrounds, mattes and overlays (as After Effects does). if cur.is_shape() && resp.double_clicked() - && let Some((w, h)) = app.session.active_comp().map(|c| (c.width, c.height)) - && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.newShape", json!({"kind": crate::panels::viewer::shape_kind(cur), "size": [w, h]})) + && let Err(e) = comp_sized_shape(app, ui.ctx(), cur) { app.ui.status = e; } @@ -843,4 +858,46 @@ mod tests { frame(&mut app, &ctx, 2.1, vec![]); assert!(!app.auto.elements.iter().any(|w| w.id == "tools.select.TypeVertical")); } + + /// A polygon or star fills the shorter side; the other kinds fill the frame; all make a new layer. + #[test] + fn a_comp_sized_shape_fits_the_frame_for_every_kind() { + for (tool, size) in [ + (Tool::Rectangle, [1920.0, 1080.0]), + (Tool::RoundedRect, [1920.0, 1080.0]), + (Tool::Ellipse, [1920.0, 1080.0]), + (Tool::Polygon, [1080.0, 1080.0]), + (Tool::Star, [1080.0, 1080.0]), + ] { + let p = comp_sized_shape_params(tool, 1920, 1080); + assert_eq!(p["size"], json!(size), "{tool:?}"); + assert_eq!(p["position"], json!([960.0, 540.0])); + assert_eq!(p["newLayer"], json!(true)); + } + } + + /// The double-click shape takes the Tools bar's Fill, and is a new layer even with a shape layer + /// selected (not a group in that layer). + #[test] + fn the_double_clicked_shape_uses_the_toolbar_paint_on_a_new_layer() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let first = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.toolOptions", json!({"fill": "#00ff00", "stroke": false})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + assert_eq!(app.session.state.selected_layers.first().map(|l| l.0), Some(first), "a shape layer is selected"); + comp_sized_shape(&mut app, &ctx, Tool::Star).unwrap(); + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2, "a new layer, not a group in the selected one"); + let layer = comp.layers.iter().find(|l| l.id.0 != first).unwrap(); + let mut fill = None; + layer.props.walk("", &mut |_, pr| { + if fill.is_none() && matches!(pr.value, effectcraft_engine::keyframe::Value::Color(_)) { + fill = Some(pr.value.clone()); + } + }); + let fill = fill.expect("a fill colour"); + assert_eq!(fill, effectcraft_engine::keyframe::Value::Color([0.0, 1.0, 0.0, 1.0]), "the Tools bar's Fill, not the default blue"); + } } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 2c99274ed..f1958fb5e 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -218,6 +218,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V // leaves the active composition open (it used to close the composition next to it). if id == "file.close" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::RenderQueue { app.close_panel(PanelKind::RenderQueue); + // The focus goes to a panel that is still there, so the next Close acts on the composition + // instead of on the queue that is gone. + app.ui.focused = + [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| app.ui.dock.is_visible(*p)).unwrap_or(PanelKind::Project); return Ok(Value::Null); } // Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index ed35e727f..5af50f0fc 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -431,6 +431,10 @@ fn cmd_w_in_the_render_queue_closes_the_queue_not_the_comp() { assert!(!app.ui.dock.is_visible(PanelKind::RenderQueue)); assert_eq!(app.session.state.active_comp.map(|c| c.0), a); assert_eq!(app.session.state.open_comps.len(), 1); + // The focus left the closed queue: the next Close closes the composition. + assert_ne!(app.ui.focused, PanelKind::RenderQueue); + invoke(&mut app, &ctx, "file.close", json!({})).unwrap(); + assert_eq!(app.session.state.open_comps.len(), 0, "a second Close acts on the composition"); } #[test] diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 3bd46738e..a87a909a0 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -573,48 +573,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); actions.push(("__endMerge".into(), json!({}))); } - // Right-click: the Timeline's key menu, for the commands that apply from the Graph Editor. - kresp.context_menu(|ui| { - let editable_value = matches!(key.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)); - for (lbl, cmd, params) in [ - ("Edit Value…", "keys.set", kjson.clone()), - ("-", "", json!({})), - ("Copy", "keys.copy", json!({})), - ("Paste", "keys.paste", json!({})), - ("-", "", json!({})), - ("Select Equal Keyframes", "keys.selectEqual", json!({})), - ("Select Previous Keyframes", "keys.selectPrevious", json!({})), - ("Select Following Keyframes", "keys.selectFollowing", json!({})), - ("-", "", json!({})), - ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), - ("Rove Across Time", "keys.interpolation", json!({"roving": !key.roving})), - ("-", "", json!({})), - ("Easy Ease", "keys.easyEase", json!({})), - ("Easy Ease In", "keys.easyEaseIn", json!({})), - ("Easy Ease Out", "keys.easyEaseOut", json!({})), - ("-", "", json!({})), - ("Linear", "keys.interpolation", json!({"interpolation": "linear"})), - ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})), - ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})), - ("-", "", json!({})), - ("Delete", "keys.delete", json!({})), - ] { - if lbl == "-" { - ui.separator(); - continue; - } - if lbl == "Rove Across Time" && !c.prop.spatial { - continue; - } - if lbl == "Edit Value…" && !editable_value { - continue; - } - if ui.button(lbl).clicked() { - actions.push((cmd.into(), params)); - ui.close(); - } - } - }); + // Right-click: the Timeline's key menu, with the same commands. + crate::widgets::context_menu(&kresp, |ui| super::timeline::key_menu(ui, app, c.layer, c.prop, key, k.sel, actions)); } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a45d8e708..7fa5881a4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4006,71 +4006,88 @@ fn key_double_click(g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_ /// The keyframe right-click menu. fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) { let KeyHit { k, ks, ref kresp, .. } = *kh; - let t = g.tl.t; let actions = &mut g.tl.actions; let layer = rc.layer; - let uid = &prop.uid; + crate::widgets::context_menu(kresp, |ui| key_menu(ui, app, layer, prop, k, ks, actions)); +} - crate::widgets::context_menu(kresp, |ui| { - for (lbl, cmd, params) in [ - ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})), - ("-", "", json!({})), - ("Copy", "keys.copy", json!({})), - ("Paste", "keys.paste", json!({})), - ("-", "", json!({})), - ("Select Equal Keyframes", "keys.selectEqual", json!({})), - ("Select Previous Keyframes", "keys.selectPrevious", json!({})), - ("Select Following Keyframes", "keys.selectFollowing", json!({})), - ("-", "", json!({})), - ("Keyframe Interpolation…", "keys.interpolation", json!({})), - ("Keyframe Velocity…", "keys.velocity", json!({})), - ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), - ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})), - ("-", "", json!({})), - ("Easy Ease", "keys.easyEase", json!({})), - ("Easy Ease In", "keys.easyEaseIn", json!({})), - ("Easy Ease Out", "keys.easyEaseOut", json!({})), - ("Time-Reverse Keyframes", "keys.timeReverse", json!({})), - ("-", "", json!({})), - ("Linear", "keys.interpolation", json!({"interpolation": "linear"})), - ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})), - ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})), - ("Select All Keyframes", "keys.selectAll", json!({})), - ("Delete", "keys.delete", json!({})), - ] { - if lbl == "Select All Keyframes" { - // Keyframe colour labels. +/// The commands of a keyframe's right-click menu, for the Timeline and the Graph Editor alike. They act +/// on the selected keys, so a menu opened on an unselected key selects it first. A locked layer's keys +/// can still be copied and selected, but not edited. +pub(crate) fn key_menu( + ui: &mut egui::Ui, + app: &EffectcraftApp, + layer: &Layer, + prop: &Property, + k: &effectcraft_engine::keyframe::Keyframe, + selected: bool, + actions: &mut Vec<(String, serde_json::Value)>, +) { + let t = app.tokens; + let locked = layer.switches.locked; + let uid = &prop.uid; + let this_key = json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}); + for (lbl, cmd, params) in [ + ("Edit Value…", "keys.set", this_key.clone()), + ("-", "", json!({})), + ("Copy", "keys.copy", json!({})), + ("Paste", "keys.paste", json!({})), + ("-", "", json!({})), + ("Select Equal Keyframes", "keys.selectEqual", json!({})), + ("Select Previous Keyframes", "keys.selectPrevious", json!({})), + ("Select Following Keyframes", "keys.selectFollowing", json!({})), + ("-", "", json!({})), + ("Keyframe Interpolation…", "keys.interpolation", json!({})), + ("Keyframe Velocity…", "keys.velocity", json!({})), + ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})), + ("-", "", json!({})), + ("Easy Ease", "keys.easyEase", json!({})), + ("Easy Ease In", "keys.easyEaseIn", json!({})), + ("Easy Ease Out", "keys.easyEaseOut", json!({})), + ("Time-Reverse Keyframes", "keys.timeReverse", json!({})), + ("-", "", json!({})), + ("Linear", "keys.interpolation", json!({"interpolation": "linear"})), + ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})), + ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})), + ("Select All Keyframes", "keys.selectAll", json!({})), + ("Delete", "keys.delete", json!({})), + ] { + if lbl == "Select All Keyframes" { + // Keyframe colour labels. + ui.add_enabled_ui(!locked, |ui| { ui.menu_button(crate::i18n::tr("Label"), |ui| { for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() { if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() { - if !ks { - actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}))); + if !selected { + actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]}))); } actions.push(("keys.setLabel".into(), json!({"label": i}))); ui.close(); } } }); - } - if lbl == "-" { - ui.separator(); - continue; - } - if lbl == "Rove Across Time" && !prop.spatial { - continue; - } - if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) { - continue; - } - if ui.button(lbl).clicked() { - if !ks { - actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}))); - } - actions.push((cmd.into(), params)); - ui.close(); - } + }); } - }); + if lbl == "-" { + ui.separator(); + continue; + } + if lbl == "Rove Across Time" && !prop.spatial { + continue; + } + if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) { + continue; + } + let edits = !matches!(cmd, "keys.copy" | "keys.selectEqual" | "keys.selectPrevious" | "keys.selectFollowing" | "keys.selectAll"); + if ui.add_enabled(!(locked && edits), egui::Button::new(crate::i18n::context_item(cmd, ¶ms, lbl))).clicked() { + if !selected { + actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]}))); + } + actions.push((cmd.into(), params)); + ui.close(); + } + } } /// Starts a key drag (or Alt-scale). diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 0355ea5ed..ee0e39ca3 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -535,3 +535,46 @@ fn disk_bar_range_covers_partial_frames() { let above_limit = TMap { start: 100_001.0, ..tm }; assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000); } + +/// The keyframe menu is one for the Timeline and the Graph Editor: its commands are drawn in the +/// interface language, and a locked layer's keys can be copied and selected but not edited. +#[test] +fn the_key_menu_is_translated_and_cannot_edit_a_locked_layers_keys() { + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 2})).unwrap(); + let id = s.execute("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(0.0, 0), (1.0, 50)] { + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let app = EffectcraftApp::new(s); + let layer = app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + let prop = layer.props.prop("transform/opacity").unwrap().clone(); + let key = prop.keys[0].clone(); + let enabled = |locked: bool, label: &str| { + let mut layer = layer.clone(); + layer.switches.locked = locked; + let mut actions = vec![]; + let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &key, true, &mut actions)); + h.run(); + !h.get_by_label(label).accesskit_node().is_disabled() + }; + for label in + ["Copy", "Select Equal Keyframes", "Select All Keyframes", "Delete", "Edit Value…", "Easy Ease", "Time-Reverse Keyframes", "Keyframe Velocity…"] + { + assert!(enabled(false, label), "{label} on an unlocked layer"); + } + for label in ["Copy", "Select Equal Keyframes", "Select All Keyframes"] { + assert!(enabled(true, label), "{label} is still available on a locked layer"); + } + for label in ["Delete", "Edit Value…", "Easy Ease", "Time-Reverse Keyframes", "Keyframe Velocity…", "Linear"] { + assert!(!enabled(true, label), "{label} is off on a locked layer"); + } + // The labels follow the interface language. + crate::i18n::set_current("es"); + let copy = crate::i18n::context_item("keys.copy", &json!({}), "Copy"); + assert_ne!(copy, "Copy", "the catalog translates Copy"); + assert!(enabled(false, ©)); + crate::i18n::set_current("en"); +} From 5b452a90fe6b617adaa3aa42ec84e59b9bc441c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:40:09 +0000 Subject: [PATCH 34/60] #1190: find the pressed checkbox and the ones crossed from the frame's ordered pointer events Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/ui-egui/src/widgets.rs | 136 +++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cc6da8ebb..f8e2933ab 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -421,15 +421,50 @@ struct CheckboxDrag { press_frame: u64, target: Option, visited: std::collections::HashSet, + /// Checkboxes the pointer crossed in the frame of the press, before the checkbox it pressed on + /// had been drawn (so the value to set was not known): they take it in the next frame. + pending: std::collections::HashSet, +} + +/// A primary-button event of the frame, in the order it happened. +#[derive(Clone, Copy, PartialEq)] +enum Step { + Press, + Move, + Release, +} + +/// The frame's primary-button events in order, each with its position. egui hit-tests the whole +/// frame at the last pointer position, so a press followed by a move in one frame would otherwise +/// be taken for a press where the pointer ended. +fn pointer_steps(ui: &Ui) -> Vec<(Step, egui::Pos2)> { + ui.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::PointerMoved(pos) => Some((Step::Move, *pos)), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } => { + Some((if *pressed { Step::Press } else { Step::Release }, *pos)) + } + _ => None, + }) + .collect() + }) } /// Returns the requested value. Mixed checkboxes (`None`) and disabled controls are -/// skipped. Each checkbox is visited once, so crossing it again cannot toggle it back. +/// skipped. Each checkbox is visited once, so crossing it again cannot toggle it back. The +/// checkbox a press lands on, and the ones the pointer passes over after it, are found from the +/// frame's ordered pointer events, not from where the pointer ended. fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option { let (pressed, down, released) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released())); let frame = ui.ctx().cumulative_frame_nr(); // clicked() reads Context input; do not call it while Context data is locked. let clicked = response.clicked(); + // Whether the pointer at `pos` is over this checkbox itself (not a window above it). + let over = |pos: egui::Pos2| response.rect.contains(pos) && ui.clip_rect().contains(pos) && ui.ctx().layer_id_at(pos) == Some(response.layer_id); + // Resolved before the data lock below: layer_id_at reads the Context. + let steps: Vec<(Step, bool)> = pointer_steps(ui).into_iter().map(|(step, pos)| (step, over(pos))).collect(); ui.data_mut(|data| { let drag = data.get_temp_mut_or_default::(egui::Id::unique("checkbox-drag")); if (!down && !released) || (pressed && drag.press_frame != frame) { @@ -439,10 +474,43 @@ fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option { + press_seen = true; + if drag.target.is_none() && over { + drag.target = Some(!on); + drag.visited.insert(response.id); + return Some(!on); + } + } + Step::Move if press_seen || down => match drag.target { + Some(target) if over => { + if drag.visited.insert(response.id) && on != target { + return Some(target); + } + } + None if press_seen && over => { + drag.pending.insert(response.id); + } + _ => {} + }, + _ => {} + } + } + // The pointer held still over this checkbox in a later frame of the gesture. + if down + && !pressed && response.contains_pointer() && let Some(target) = drag.target { @@ -971,6 +1039,66 @@ mod tests { assert_eq!(values[1], Some(false), "hover after release must not paint"); } + fn button(y: f32, pressed: bool) -> Vec { + let pos = pos2(28.0, y); + vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }] + } + + fn moved(y: f32) -> egui::Event { + egui::Event::PointerMoved(pos2(28.0, y)) + } + + /// A press and the moves after it can arrive in one frame (a quick flick): the press belongs to + /// the checkbox it landed on, not to the one the pointer ended over, and the ones passed over + /// follow the pressed one's new state, whether they are drawn before it or after it. + #[test] + fn a_press_and_a_move_in_one_frame_start_on_the_checkbox_pressed() { + // Rows are 30 apart, from y = 28; index 3 is disabled. + let (a, b) = (28.0, 58.0); + // Pressed on the first, moved to the second (drawn after it). + let ctx = egui::Context::default(); + let mut values = [Some(false); 5]; + values[2] = None; + checkbox_frame(&ctx, &mut values, vec![]); + let mut events = vec![moved(a)]; + events.extend(button(a, true)); + events.push(moved(b)); + checkbox_frame(&ctx, &mut values, events); + assert_eq!((values[0], values[1]), (Some(true), Some(true)), "the pressed one is the source, the one passed over follows"); + let mut events = vec![moved(b)]; + events.extend(button(b, false)); + checkbox_frame(&ctx, &mut values, events); + assert_eq!((values[0], values[1]), (Some(true), Some(true)), "release changes nothing"); + // Pressed on the second, moved to the first (drawn before it): the first takes the state in + // the next frame, while the button is still down. + let ctx = egui::Context::default(); + let mut values = [Some(false); 5]; + values[2] = None; + checkbox_frame(&ctx, &mut values, vec![]); + let mut events = vec![moved(b)]; + events.extend(button(b, true)); + events.push(moved(a)); + checkbox_frame(&ctx, &mut values, events); + assert_eq!(values[1], Some(true), "the pressed one is the source"); + checkbox_frame(&ctx, &mut values, vec![]); + assert_eq!(values[0], Some(true), "the one passed over takes the state"); + let mut events = vec![moved(a)]; + events.extend(button(a, false)); + checkbox_frame(&ctx, &mut values, events); + assert_eq!((values[0], values[1]), (Some(true), Some(true))); + // Pressed outside every checkbox, then moved onto one in the same frame: nothing starts. + let ctx = egui::Context::default(); + let mut values = [Some(false); 5]; + values[2] = None; + checkbox_frame(&ctx, &mut values, vec![]); + let mut events = vec![moved(200.0)]; + events.extend(button(200.0, true)); + events.push(moved(a)); + checkbox_frame(&ctx, &mut values, events); + checkbox_frame(&ctx, &mut values, vec![]); + assert_eq!(values, [Some(false), Some(false), None, Some(false), Some(false)], "a press outside starts no gesture"); + } + #[test] fn checkbox_click_and_keyboard_toggle_once_and_drag_must_start_on_a_checkbox() { let ctx = egui::Context::default(); From c4ca4dc0e9e220e3834e10846517dc5390d1e547 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:01:42 +0000 Subject: [PATCH 35/60] #1284: disable key-menu commands that need selecting an unselected key on a locked layer Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCR4xUtUfBJwkhwaFuD --- crates/ui-egui/src/panels/timeline.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7fa5881a4..b94519713 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4080,7 +4080,9 @@ pub(crate) fn key_menu( continue; } let edits = !matches!(cmd, "keys.copy" | "keys.selectEqual" | "keys.selectPrevious" | "keys.selectFollowing" | "keys.selectAll"); - if ui.add_enabled(!(locked && edits), egui::Button::new(crate::i18n::context_item(cmd, ¶ms, lbl))).clicked() { + // An unselected key is selected before the command runs, and locked keys cannot be selected. + let unselectable = locked && !selected && cmd != "keys.selectAll"; + if ui.add_enabled(!(locked && edits) && !unselectable, egui::Button::new(crate::i18n::context_item(cmd, ¶ms, lbl))).clicked() { if !selected { actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]}))); } From 90874c3dd2b0122740355337bd0103200111d1ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:28:09 +0000 Subject: [PATCH 36/60] #1190: apply checkbox crossings recorded before the target was known, even after release Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCR4xUtUfBJwkhwaFuD --- crates/ui-egui/src/widgets.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f8e2933ab..cd00bffb1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -467,7 +467,9 @@ fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option = pointer_steps(ui).into_iter().map(|(step, pos)| (step, over(pos))).collect(); ui.data_mut(|data| { let drag = data.get_temp_mut_or_default::(egui::Id::unique("checkbox-drag")); - if (!down && !released) || (pressed && drag.press_frame != frame) { + // A release in the press frame leaves crossings pending: keep them for the next frame. + let flushing = !drag.pending.is_empty() && drag.target.is_some() && frame <= drag.press_frame.saturating_add(1); + if (!down && !released && !flushing) || (pressed && drag.press_frame != frame) { *drag = CheckboxDrag { press_frame: frame, ..Default::default() }; } let on = value?; @@ -475,8 +477,7 @@ fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option Date: Thu, 29 Oct 2026 14:16:36 +0000 Subject: [PATCH 37/60] #1190: a move after the release in the same frame does not carry the checkbox drag Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/ui-egui/src/widgets.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cd00bffb1..9c4f76e72 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -484,18 +484,26 @@ fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option *step == Step::Release, + None => down, + }; let mut press_seen = false; for (step, over) in steps { match step { Step::Press => { press_seen = true; + held = true; if drag.target.is_none() && over { drag.target = Some(!on); drag.visited.insert(response.id); return Some(!on); } } - Step::Move if press_seen || down => match drag.target { + Step::Release => held = false, + Step::Move if held => match drag.target { Some(target) if over => { if drag.visited.insert(response.id) && on != target { return Some(target); @@ -1113,6 +1121,24 @@ mod tests { assert_eq!((values[0], values[1]), (Some(true), Some(true)), "a crossing before the release still takes the state"); } + /// A move that arrives after the release, in the frame of the click, is the pointer travelling without the button: + /// it must not carry the gesture onto the checkbox it ends over. + #[test] + fn a_move_after_the_release_in_the_same_frame_paints_nothing() { + let (a, b) = (28.0, 58.0); + let ctx = egui::Context::default(); + let mut values = [Some(false); 5]; + values[2] = None; + checkbox_frame(&ctx, &mut values, vec![]); + let mut events = vec![moved(a)]; + events.extend(button(a, true)); + events.extend(button(a, false)); + events.push(moved(b)); + checkbox_frame(&ctx, &mut values, events); + checkbox_frame(&ctx, &mut values, vec![]); + assert_eq!((values[0], values[1]), (Some(true), Some(false)), "the clicked one changes, the one moved onto afterwards does not"); + } + #[test] fn checkbox_click_and_keyboard_toggle_once_and_drag_must_start_on_a_checkbox() { let ctx = egui::Context::default(); From 1dc5d2db69b65e1227009a4d2dd9502292949df7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:53:52 +0000 Subject: [PATCH 38/60] #1284: Easy Ease, interpolation, Toggle Hold, labels and Time-Reverse skip a locked layer's keys and keep them selected Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/engine/src/cmd/anim.rs | 8 +++++++- crates/engine/src/cmd/prop.rs | 11 ++++++++++- crates/engine/src/tests/delete_keys.rs | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs index 76bfb5a63..1aecf3b5d 100644 --- a/crates/engine/src/cmd/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -498,7 +498,13 @@ fn time_reverse(s: &mut Session, _: &Value) -> Result { } let mut new_sel = vec![]; for ((lid, uid), times) in groups { - let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue }; + let Some(layer) = comp.layer_mut(lid) else { continue }; + // A locked layer's keys stay as they are, and stay selected. + if layer.switches.locked { + new_sel.extend(times.iter().map(|time| KeyRef { layer: lid, prop: uid, time: *time })); + continue; + } + let Some(pr) = layer.props.find_mut(uid) else { continue }; let (mut picked, rest): (Vec, Vec) = pr.keys.drain(..).partition(|k| times.contains(&k.time)); effectcraft_keyframe::time_reverse(&mut picked); new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time })); diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 36f76706f..0eff1dd09 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -895,7 +895,16 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im groups.entry((k.layer, k.prop)).or_default().push(k.time); } for ((lid, uid), times) in groups { - let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue }; + let Some(layer) = comp.layer_mut(lid) else { continue }; + // A locked layer's keys stay as they are, and stay selected. + if layer.switches.locked { + for time in times { + let r = KeyRef { layer: lid, prop: uid, time }; + remapped.insert(r, r); + } + continue; + } + let Some(pr) = layer.props.find_mut(uid) else { continue }; // Process each stored key once, in an order that avoids collisions when moving. let mut idx: Vec<(usize, Tick)> = times.iter().filter_map(|t| key_at(&pr.keys, *t).map(|i| (i, *t))).collect(); idx.sort_by_key(|x| x.0); diff --git a/crates/engine/src/tests/delete_keys.rs b/crates/engine/src/tests/delete_keys.rs index 029104497..829331fd7 100644 --- a/crates/engine/src/tests/delete_keys.rs +++ b/crates/engine/src/tests/delete_keys.rs @@ -64,3 +64,23 @@ fn a_locked_layers_keys_cannot_be_deleted_or_edited() { s.execute_checked("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap(); assert_eq!(s.execute_checked("keys.delete", json!({})).unwrap(), json!(3)); } + +/// Easy Ease, Toggle Hold and Time-Reverse leave a locked layer's keys as they are and keep them +/// selected; unlocked, they change them. +#[test] +fn selection_based_key_edits_skip_a_locked_layer() { + let (mut s, layer, prop) = setup(); + let keys = keys_of(&s, layer, prop); + let stored = |s: &Session| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone(); + let before = stored(&s); + s.state.selected_keys = keys.clone(); + s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap(); + for command in ["keys.easyEase", "keys.toggleHold", "keys.timeReverse"] { + s.execute_checked(command, json!({})).unwrap(); + assert_eq!(stored(&s), before, "{command} changed a locked layer's keys"); + assert_eq!(s.state.selected_keys, keys, "{command} dropped the selection of a locked layer's keys"); + } + s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap(); + s.execute_checked("keys.easyEase", json!({})).unwrap(); + assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys"); +} From 37e0c5dff62c8b375db80b86b849781b4a4c798a Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Thu, 29 Oct 2026 19:38:48 +0100 Subject: [PATCH 39/60] #1284: reject Graph Editor ease edits on locked layers --- crates/engine/src/cmd/anim.rs | 3 +++ crates/engine/src/tests/timeline.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs index 1aecf3b5d..11aad5e8b 100644 --- a/crates/engine/src/cmd/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -352,6 +352,9 @@ fn set_ease(s: &mut Session, p: &Value) -> Result { let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0)); s.edit("Keyframe Velocity", merge_p(p), |proj, _| { let l = super::layer_mut(proj, cid, lid)?; + if l.switches.locked { + return Err(bad("keys.setEase", "the layer is locked")); + } let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?; let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?; let spatial = pr.spatial; diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 1330f0b09..c96053bf2 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -344,6 +344,34 @@ fn graph_editor_key_and_handle_edits() { assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear); } +#[test] +fn graph_editor_ease_edits_reject_a_locked_layer() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]); + select_prop_keys(&mut s, l, "transform/opacity"); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": true})).unwrap(); + let before = prop(&s, l, "transform/opacity"); + let selected = s.state.selected_keys.clone(); + let undo = s.history.undo.len(); + for side in ["in", "out"] { + let error = + s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": side, "speed": 200, "influence": 60})).unwrap_err(); + assert!(error.to_string().contains("locked"), "{error}"); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + assert_eq!(s.state.selected_keys, selected); + assert_eq!(s.history.undo.len(), undo); + } + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": false})).unwrap(); + s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": "out", "speed": 200, "influence": 60})).unwrap(); + let after = prop(&s, l, "transform/opacity"); + assert_eq!(after.keys[1].out_interp, Interp::Bezier); + assert_eq!(after.keys[1].out_ease[0].speed, 200.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, after.keys); +} + #[test] fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() { let (mut s, l) = setup(); From 5d02db47408f5c4facf68250d7c3cf05371315be Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Thu, 29 Oct 2026 20:01:08 +0100 Subject: [PATCH 40/60] #1284: preserve locked keys during group transforms and cover unselected menus --- crates/engine/src/cmd/key_transform.rs | 4 +++ crates/engine/src/tests/delete_keys.rs | 39 +++++++++++++++++++++ crates/ui-egui/src/panels/timeline/tests.rs | 17 +++++---- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/key_transform.rs b/crates/engine/src/cmd/key_transform.rs index b78439266..641759095 100644 --- a/crates/engine/src/cmd/key_transform.rs +++ b/crates/engine/src/cmd/key_transform.rs @@ -72,6 +72,10 @@ fn transform(s: &mut Session, p: &Value) -> Result { let mut new_sel = vec![]; for ((lid, uid), times) in groups { let Some(layer) = comp.layer_mut(lid) else { continue }; + if layer.switches.locked { + new_sel.extend(times.into_iter().map(|time| KeyRef { layer: lid, prop: uid, time })); + continue; + } let (start, stretch) = (layer.start_time, layer.stretch); let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) }; let to_layer = |ct: Tick| { diff --git a/crates/engine/src/tests/delete_keys.rs b/crates/engine/src/tests/delete_keys.rs index 829331fd7..fb8162855 100644 --- a/crates/engine/src/tests/delete_keys.rs +++ b/crates/engine/src/tests/delete_keys.rs @@ -84,3 +84,42 @@ fn selection_based_key_edits_skip_a_locked_layer() { s.execute_checked("keys.easyEase", json!({})).unwrap(); assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys"); } + +#[test] +fn key_transform_preserves_locked_keys_in_a_mixed_selection() { + let (mut s, locked, locked_prop) = setup(); + let unlocked = s.execute("layer.newSolid", json!({"name": "Unlocked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for t in 0..3 { + s.execute("prop.addKey", json!({"layer": unlocked, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap(); + } + let unlocked_prop = s.active_comp().unwrap().layer(LayerId(unlocked)).unwrap().props.prop("transform/opacity").unwrap().uid; + let locked_keys = keys_of(&s, locked, locked_prop); + s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, unlocked, unlocked_prop).iter()).copied().collect(); + s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap(); + let stored = |s: &Session, layer, prop| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone(); + let before = stored(&s, locked, locked_prop); + let undo = s.history.undo.len(); + for offset in [0.5, 1.0] { + s.execute("keys.transform", json!({"timeOffset": offset, "valueOffset": offset * 10.0, "merge": "mixed-transform", "fromStart": true})).unwrap(); + assert_eq!(stored(&s, locked, locked_prop), before); + assert_eq!(s.state.selected_keys.len(), 6); + assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)), "locked selections stay at their original times"); + assert!(keys_of(&s, unlocked, unlocked_prop).iter().all(|k| s.state.selected_keys.contains(k)), "unlocked selections follow the keys"); + let moved = stored(&s, unlocked, unlocked_prop); + for (i, key) in moved.iter().enumerate() { + assert_eq!(key.time.seconds(), i as f64 + offset); + assert_eq!(key.value.as_f64(), i as f64 * 25.0 + offset * 10.0); + } + } + assert_eq!(s.history.undo.len(), undo + 1, "one merged drag"); + let after = stored(&s, unlocked, unlocked_prop); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(stored(&s, locked, locked_prop), before); + assert_eq!(stored(&s, unlocked, unlocked_prop), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(stored(&s, locked, locked_prop), before); + assert_eq!(stored(&s, unlocked, unlocked_prop), after); + s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": false})).unwrap(); + s.execute("keys.transform", json!({"timeOffset": 0.5, "valueOffset": 10})).unwrap(); + assert_ne!(stored(&s, locked, locked_prop), before, "unlocking makes the keys editable again"); +} diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index ee0e39ca3..4b7bdefd5 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -552,29 +552,34 @@ fn the_key_menu_is_translated_and_cannot_edit_a_locked_layers_keys() { let layer = app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); let prop = layer.props.prop("transform/opacity").unwrap().clone(); let key = prop.keys[0].clone(); - let enabled = |locked: bool, label: &str| { + let enabled = |locked: bool, selected: bool, label: &str| { let mut layer = layer.clone(); layer.switches.locked = locked; let mut actions = vec![]; - let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &key, true, &mut actions)); + let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &key, selected, &mut actions)); h.run(); !h.get_by_label(label).accesskit_node().is_disabled() }; for label in ["Copy", "Select Equal Keyframes", "Select All Keyframes", "Delete", "Edit Value…", "Easy Ease", "Time-Reverse Keyframes", "Keyframe Velocity…"] { - assert!(enabled(false, label), "{label} on an unlocked layer"); + assert!(enabled(false, true, label), "{label} on an unlocked layer"); } for label in ["Copy", "Select Equal Keyframes", "Select All Keyframes"] { - assert!(enabled(true, label), "{label} is still available on a locked layer"); + assert!(enabled(true, true, label), "{label} is still available on a locked layer"); } for label in ["Delete", "Edit Value…", "Easy Ease", "Time-Reverse Keyframes", "Keyframe Velocity…", "Linear"] { - assert!(!enabled(true, label), "{label} is off on a locked layer"); + assert!(!enabled(true, true, label), "{label} is off on a locked layer"); + assert!(!enabled(true, false, label), "{label} is off on an unselected locked key"); } + for label in ["Copy", "Select Equal Keyframes", "Select Previous Keyframes", "Select Following Keyframes"] { + assert!(!enabled(true, false, label), "{label} requires selecting the clicked key"); + } + assert!(enabled(true, false, "Select All Keyframes")); // The labels follow the interface language. crate::i18n::set_current("es"); let copy = crate::i18n::context_item("keys.copy", &json!({}), "Copy"); assert_ne!(copy, "Copy", "the catalog translates Copy"); - assert!(enabled(false, ©)); + assert!(enabled(false, true, ©)); crate::i18n::set_current("en"); } From 0c4668df85cb4cd9e839ef8aa0c635fe98824c74 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Thu, 29 Oct 2026 20:59:48 +0100 Subject: [PATCH 41/60] Graph Editor: preserve locked keys across gestures (#1342) --- crates/engine/src/cmd/key_transform.rs | 3 + crates/engine/src/tests/delete_keys.rs | 29 +++++++++ crates/ui-egui/src/header.rs | 22 ++++++- crates/ui-egui/src/panels/graph.rs | 19 ++++-- crates/ui-egui/src/panels/timeline/tests.rs | 31 +++++++++ crates/ui-egui/tests/it/ui_graph_fit.rs | 72 +++++++++++++++++++++ 6 files changed, 168 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/key_transform.rs b/crates/engine/src/cmd/key_transform.rs index 641759095..397db4de5 100644 --- a/crates/engine/src/cmd/key_transform.rs +++ b/crates/engine/src/cmd/key_transform.rs @@ -126,6 +126,9 @@ fn spatial_tangents(s: &mut Session, p: &Value) -> Result { let brk = b_p(p, "break").unwrap_or(false); s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| { let l = super::layer_mut(proj, cid, lid)?; + if l.switches.locked { + return Err(bad("keys.setSpatialTangents", "the layer is locked")); + } let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?; if !pr.spatial { return Err(bad("keys.setSpatialTangents", "not a spatial property")); diff --git a/crates/engine/src/tests/delete_keys.rs b/crates/engine/src/tests/delete_keys.rs index fb8162855..457808a92 100644 --- a/crates/engine/src/tests/delete_keys.rs +++ b/crates/engine/src/tests/delete_keys.rs @@ -85,6 +85,35 @@ fn selection_based_key_edits_skip_a_locked_layer() { assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys"); } +#[test] +fn spatial_handles_cannot_edit_a_locked_layer() { + let (mut s, layer, _) = setup(); + for (time, value) in [(0, [10, 20, 0]), (1, [80, 60, 0])] { + s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": time, "value": value})).unwrap(); + } + let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid; + s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": prop, "time": 0}]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap(); + let before = s.project.to_json(); + let selected = s.state.selected_keys.clone(); + let undo_len = s.history.undo.len(); + let params = json!({"layer": layer, "prop": prop, "time": 0, "out": [15, 25, 0]}); + let error = s.execute("keys.setSpatialTangents", params.clone()).unwrap_err().to_string(); + assert!(error.contains("locked"), "{error}"); + assert_eq!(s.project.to_json(), before); + assert_eq!(s.state.selected_keys, selected); + assert_eq!(s.history.undo.len(), undo_len); + s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap(); + let unlocked = s.project.to_json(); + s.execute("keys.setSpatialTangents", params).unwrap(); + let edited = s.project.to_json(); + assert_ne!(edited, unlocked); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), unlocked); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), edited); +} + #[test] fn key_transform_preserves_locked_keys_in_a_mixed_selection() { let (mut s, locked, locked_prop) = setup(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 8a336fcbb..55659e8b1 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -883,21 +883,41 @@ mod tests { let mut s = effectcraft_engine::Session::default(); s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); let first = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.fillStroke", json!({"layers": [first], "fill": "#00ff00", "stroke": false})).unwrap(); s.execute("shape.toolOptions", json!({"fill": "#00ff00", "stroke": false})).unwrap(); let mut app = EffectcraftApp::new(s); let ctx = egui::Context::default(); assert_eq!(app.session.state.selected_layers.first().map(|l| l.0), Some(first), "a shape layer is selected"); - comp_sized_shape(&mut app, &ctx, Tool::Star).unwrap(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + assert_eq!(app.session.state.shape_tool.fill.color, [0.0, 1.0, 0.0], "the visible toolbar paint"); + let [x, y, w, h] = app.auto.find("tools.Rectangle").unwrap().rect; + let at = egui::pos2(x + w / 2.0, y + h / 2.0); + let pointer = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(true)]); + frame(&mut app, &ctx, 1.05, vec![pointer(false)]); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1, "a single click only selects the tool"); + // A separate double-click must route through the toolbar response, not just its helper. + for (time, pressed) in [(2.0, true), (2.05, false), (2.1, true), (2.15, false)] { + frame(&mut app, &ctx, time, vec![pointer(pressed)]); + } let comp = app.session.active_comp().unwrap(); assert_eq!(comp.layers.len(), 2, "a new layer, not a group in the selected one"); let layer = comp.layers.iter().find(|l| l.id.0 != first).unwrap(); + assert_eq!(layer.props.prop("transform/position").unwrap().value.components(), vec![200.0, 150.0, 0.0]); let mut fill = None; + let mut size = None; layer.props.walk("", &mut |_, pr| { + if pr.name == "Size" { + size = Some(pr.value.clone()); + } if fill.is_none() && matches!(pr.value, effectcraft_engine::keyframe::Value::Color(_)) { fill = Some(pr.value.clone()); } }); let fill = fill.expect("a fill colour"); + assert_eq!(size, Some(effectcraft_engine::keyframe::Value::Vec2([400.0, 300.0]))); assert_eq!(fill, effectcraft_engine::keyframe::Value::Color([0.0, 1.0, 0.0, 1.0]), "the Tools bar's Fill, not the default blue"); } } diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index a87a909a0..24b48bc86 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -456,6 +456,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![]; for c in &cs { let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim); + let unlocked = !c.layer.switches.locked; for k in &c.keys { let kp = pos2(tm.x(k.t), ymap(k.v)); // In the speed graph an end key rises with the handle being dragged; skipping it once it @@ -477,9 +478,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle"); // The same side's handles of every selected key move with it, by as much as the // pointer moved (After Effects), in one undo step. - if hresp.drag_started() { + if unlocked && hresp.drag_started() { let grabs: Vec = cs .iter() + .filter(|c2| !c2.layer.switches.locked) .flat_map(|c2| { c2.keys.iter().flat_map(move |k2| { k2.handles.iter().filter(|h2| h2.out == h.out).map(move |h2| HandleGrab { @@ -499,7 +501,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(hp); ctx.data_mut(|dd| dd.insert_temp::(handle_drag_id(), (press, grabs))); } - if hresp.dragged() + if unlocked + && hresp.dragged() && let Some(pt) = hresp.interact_pointer_pos() && let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::(handle_drag_id())) { @@ -530,7 +533,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); let mods = ui.input(|i| i.modifiers); let key = &c.prop.keys[k.idx]; - if kresp.clicked() && mods.command { + if unlocked && kresp.clicked() && mods.command { // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off (as in the Timeline). actions.push(("keys.select".into(), json!({"keys": [kjson.clone()]}))); if mods.alt { @@ -539,20 +542,22 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let linear = key.in_interp == Interp::Linear && key.out_interp == Interp::Linear; actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }}))); } - } else if kresp.clicked() && mods.shift { + } else if unlocked && kresp.clicked() && mods.shift { actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true}))); - } else if (kresp.clicked() || kresp.drag_started() || kresp.secondary_clicked()) && !k.sel { + } else if unlocked && (kresp.clicked() || kresp.drag_started() || kresp.secondary_clicked()) && !k.sel { actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift}))); } // A drag moves every selected key, by how far the pointer went from where the button // went down (the key doesn't jump under the pointer); Shift keeps it to the time or // the value axis, whichever moved more. - if kresp.drag_started() + if unlocked + && kresp.drag_started() && let Some(o) = ui.input(|i| i.pointer.press_origin()) { ctx.data_mut(|dd| dd.insert_temp(key_grab_id(), KeyGrab { press: o, t: k.t })); } - if kresp.dragged() + if unlocked + && kresp.dragged() && let Some(pt) = kresp.interact_pointer_pos() && let Some(g) = ctx.data(|dd| dd.get_temp::(key_grab_id())) { diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 4b7bdefd5..c46699eeb 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -583,3 +583,34 @@ fn the_key_menu_is_translated_and_cannot_edit_a_locked_layers_keys() { assert!(enabled(false, true, ©)); crate::i18n::set_current("en"); } + +#[test] +fn a_key_menu_edit_selects_the_clicked_key_before_applying_the_command() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 64, "height": 64, "duration": 2})).unwrap(); + let id = s.execute("layer.newSolid", json!({"color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, 0), (1.0, 50)] { + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": time, "value": value})).unwrap(); + } + let layer = s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + let prop = layer.props.prop("transform/opacity").unwrap().clone(); + s.execute("keys.select", json!({"keys": [{"layer": id, "prop": prop.uid, "time": 1.0}]})).unwrap(); + let mut app = EffectcraftApp::new(s); + let mut actions = vec![]; + { + let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &prop.keys[0], false, &mut actions)); + h.run(); + h.get_by_label("Easy Ease").click(); + h.run(); + } + assert_eq!(actions.iter().map(|(command, _)| command.as_str()).collect::>(), ["keys.select", "keys.easyEase"]); + for (command, params) in actions { + app.session.execute(&command, params).unwrap(); + } + let keys = &app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.find(prop.uid).unwrap().keys; + assert_ne!(keys[0], prop.keys[0], "the clicked key was eased"); + assert_eq!(keys[1], prop.keys[1], "the previously selected key was not edited"); + assert_eq!(app.session.state.selected_keys.len(), 1); + assert_eq!(app.session.state.selected_keys[0].time, prop.keys[0].time); +} diff --git a/crates/ui-egui/tests/it/ui_graph_fit.rs b/crates/ui-egui/tests/it/ui_graph_fit.rs index c161dfef6..d80ae315e 100644 --- a/crates/ui-egui/tests/it/ui_graph_fit.rs +++ b/crates/ui-egui/tests/it/ui_graph_fit.rs @@ -77,3 +77,75 @@ fn open_graph_toggle_twice(h: &mut Harness<'_, EffectcraftApp>) { assert!(!h.state().ui.timeline.graph_editor); open_graph(h); } + +#[test] +fn locked_graph_keys_preserve_the_existing_selection_and_keyframes() { + use egui::{Event, Modifiers, PointerButton, pos2, vec2}; + for (button, modifiers, drag) in [ + (PointerButton::Primary, Modifiers::NONE, false), + (PointerButton::Primary, Modifiers::SHIFT, false), + (PointerButton::Primary, Modifiers::COMMAND, false), + (PointerButton::Secondary, Modifiers::NONE, false), + (PointerButton::Primary, Modifiers::NONE, true), + ] { + let (mut h, layer, uid) = harness(true); + let s = &mut h.state_mut().session; + let locked = s.execute("layer.newSolid", json!({"name": "Locked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(9.0, 100.0), (11.0, 200.0)] { + s.execute("prop.addKey", json!({"layer": locked, "path": "transform/rotation", "time": time, "value": value})).unwrap(); + } + let locked_id = effectcraft_engine::project::LayerId(locked); + let locked_uid = s.active_comp().unwrap().layer(locked_id).unwrap().props.prop("transform/rotation").unwrap().uid; + s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap(); + s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": uid, "time": 12.0}]})).unwrap(); + s.state.selected_props = vec![(effectcraft_engine::project::LayerId(layer), uid), (locked_id, locked_uid)]; + let selection = s.state.selected_keys.clone(); + let before = s.project.clone(); + open_graph(&mut h); + h.state_mut().ui.timeline.graph_auto_zoom = false; + h.state_mut().ui.timeline.graph_range = Some((0.0, 800.0)); + h.run_steps(3); + let [x, y, w, height] = h.state().auto.find(&format!("timeline.graph.key.{locked_uid}.0.0")).expect("locked key drawn").rect; + let point = pos2(x + w / 2.0, y + height / 2.0); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(point)); + h.input_mut().events.push(Event::PointerButton { pos: point, button, pressed: true, modifiers }); + h.step(); + let end = if drag { point + vec2(30.0, -20.0) } else { point }; + h.input_mut().events.push(Event::PointerMoved(end)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: end, button, pressed: false, modifiers }); + h.run_steps(3); + assert_eq!(h.state().session.state.selected_keys, selection, "{button:?}, {modifiers:?}, drag={drag}"); + assert_eq!( + serde_json::to_value(&h.state().session.project).unwrap(), + serde_json::to_value(&before).unwrap(), + "locked gestures must not edit another key" + ); + } +} + +#[test] +fn an_unlocked_graph_keys_menu_edits_the_clicked_key() { + use egui::{Event, Modifiers, PointerButton, pos2}; + use egui_kittest::kittest::Queryable; + let (mut h, layer, uid) = harness(true); + h.state_mut().session.execute("keys.select", json!({"keys": [{"layer": layer, "prop": uid, "time": 12.0}]})).unwrap(); + open_graph(&mut h); + let [x, y, w, height] = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.0")).unwrap().rect; + let at = pos2(x + w / 2.0, y + height / 2.0); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + let selected = &h.state().session.state.selected_keys; + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].time.seconds(), 8.0); + h.get_by_label("Easy Ease").click(); + h.run_steps(3); + let prop = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(uid).unwrap(); + assert_eq!(prop.keys[0].out_interp, effectcraft_engine::keyframe::Interp::Bezier); + assert_eq!(prop.keys[1].out_interp, effectcraft_engine::keyframe::Interp::Linear); +} From e32eaa03ad8adef265588717a55de1c7d8311c28 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Thu, 29 Oct 2026 21:31:23 +0100 Subject: [PATCH 42/60] Graph Editor: close remaining lock and menu gaps (#1342) --- crates/engine/src/cmd/anim.rs | 14 ++++-- crates/engine/src/cmd/shape_stroke.rs | 4 +- crates/engine/src/tests/delete_keys.rs | 67 ++++++++++++++++++++++++++ crates/ui-egui/src/header.rs | 36 ++++++++++++++ crates/ui-egui/src/menus/entries.rs | 3 ++ crates/ui-egui/src/menus/invoke.rs | 7 +-- crates/ui-egui/src/menus/tests.rs | 36 ++++++++++++++ 7 files changed, 160 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs index 11aad5e8b..073262d0f 100644 --- a/crates/engine/src/cmd/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -89,6 +89,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { let mut plan: Vec<(LayerId, Uid, usize)> = vec![]; for (ti, lid) in targets.iter().enumerate() { let Some(l) = comp.layer(*lid) else { continue }; + if l.switches.locked { + continue; + } for (ci, c) in clip.iter().enumerate() { if paired && sources.get(ti) != Some(&c.layer) { continue; @@ -110,11 +113,12 @@ fn paste(s: &mut Session, p: &Value) -> Result { } } if plan.is_empty() { - return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)")); + return Err(bad("keys.paste", "no compatible unlocked property to paste into (unlock the target or select a property of the same type)")); } s.edit("Paste Keyframes", merge_p(p), |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let mut sel = vec![]; + let mut sel: Vec = st.selected_keys.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect(); + let retained = sel.len(); for (lid, uid, ci) in &plan { let Some(l) = comp.layer_mut(*lid) else { continue }; let times: Vec = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect(); @@ -136,8 +140,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { } retime_roving(&mut pr.keys, pr.spatial); } + let pasted = sel.len().saturating_sub(retained); st.selected_keys = sel; - Ok(json!(st.selected_keys.len())) + Ok(json!(pasted)) }) } @@ -175,6 +180,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let t = time_p(s, p, Some(comp)); let target = comp.layer(lid).ok_or(EngineError::NoComp)?; + if target.switches.locked { + return Err(bad("keys.paste", "the target layer is locked")); + } let mut order: Vec = (0..path.vertices.len()).collect(); if path.closed && !order.is_empty() { order.push(0); diff --git a/crates/engine/src/cmd/shape_stroke.rs b/crates/engine/src/cmd/shape_stroke.rs index ecd3ed8e0..e81103cdf 100644 --- a/crates/engine/src/cmd/shape_stroke.rs +++ b/crates/engine/src/cmd/shape_stroke.rs @@ -216,10 +216,12 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4 "fill" if fill.is_none() => fill = at("color").map(rgba), "stroke" if stroke.is_none() => { stroke = at("color").map(rgba); - width = at("width").map(|v| v.as_f64()); } _ => {} } + if width.is_none() && matches!(g.match_id.as_str(), "stroke" | "gstroke") { + width = at("width").map(|v| v.as_f64()); + } }); } (fill, stroke, width) diff --git a/crates/engine/src/tests/delete_keys.rs b/crates/engine/src/tests/delete_keys.rs index 457808a92..a6ca08bed 100644 --- a/crates/engine/src/tests/delete_keys.rs +++ b/crates/engine/src/tests/delete_keys.rs @@ -114,6 +114,73 @@ fn spatial_handles_cannot_edit_a_locked_layer() { assert_eq!(s.project.to_json(), edited); } +#[test] +fn pasting_keys_or_motion_paths_cannot_edit_locked_targets() { + for path_clip in [false, true] { + for explicit in [false, true] { + let (mut s, layer, opacity) = setup(); + let target_path = if path_clip { "transform/position" } else { "transform/opacity" }; + let source = if path_clip { + s.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap(); + s.execute("prop.toggleAnimation", json!({"layer": layer, "path": "masks/#1/path"})).unwrap(); + s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/path").unwrap().uid + } else { + opacity + }; + s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": source, "time": 0}]})).unwrap(); + s.execute("keys.copy", json!({})).unwrap(); + s.execute("prop.select", json!({"layer": layer, "path": target_path})).unwrap(); + s.execute("time.set", json!({"time": 3})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap(); + let params = if explicit { json!({"layer": layer, "path": target_path}) } else { json!({}) }; + let before = s.project.to_json(); + let selected = s.state.selected_keys.clone(); + let undo_len = s.history.undo.len(); + let result = s.execute("keys.paste", params.clone()); + assert!(result.is_err(), "path={path_clip}, explicit={explicit}: {result:?}"); + assert_eq!(s.project.to_json(), before); + assert_eq!(s.state.selected_keys, selected); + assert_eq!(s.history.undo.len(), undo_len); + s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap(); + let unlocked = s.project.to_json(); + s.execute("keys.paste", params).unwrap(); + let pasted = s.project.to_json(); + assert_ne!(pasted, unlocked); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), unlocked); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), pasted); + } + } +} + +#[test] +fn key_paste_skips_locked_targets_without_changing_layer_pairing() { + let (mut s, locked, locked_prop) = setup(); + let other = s.execute("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0, 100), (1, 80), (2, 60)] { + s.execute("prop.addKey", json!({"layer": other, "path": "transform/opacity", "time": time, "value": value})).unwrap(); + } + let other_prop = s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.prop("transform/opacity").unwrap().uid; + let locked_keys = keys_of(&s, locked, locked_prop); + s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, other, other_prop).iter()).copied().collect(); + s.execute("keys.copy", json!({})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap(); + s.execute("time.set", json!({"time": 3})).unwrap(); + let before = s.project.to_json(); + assert_eq!(s.execute("keys.paste", json!({"layers": [locked, other]})).unwrap(), json!(3), "only newly pasted keys are counted"); + assert_eq!(keys_of(&s, locked, locked_prop), locked_keys); + assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k))); + assert_eq!(s.state.selected_keys.len(), 6); + let keys = &s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.find(other_prop).unwrap().keys; + assert_eq!(keys.iter().filter(|k| k.time.seconds() >= 3.0).map(|k| k.value.as_f64()).collect::>(), [100.0, 80.0, 60.0]); + let pasted = s.project.to_json(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), pasted); +} + #[test] fn key_transform_preserves_locked_keys_in_a_mixed_selection() { let (mut s, locked, locked_prop) = setup(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 55659e8b1..2c14e188b 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -920,4 +920,40 @@ mod tests { assert_eq!(size, Some(effectcraft_engine::keyframe::Value::Vec2([400.0, 300.0]))); assert_eq!(fill, effectcraft_engine::keyframe::Value::Color([0.0, 1.0, 0.0, 1.0]), "the Tools bar's Fill, not the default blue"); } + + #[test] + fn double_clicked_shapes_keep_the_selected_gradient_stroke_width() { + for kind in ["solid", "linear", "radial"] { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + s.execute("layer.newShape", json!({"kind": "rect"})).unwrap(); + s.execute("shape.fillStroke", json!({"fill": false, "strokeType": kind, "strokeWidth": 9})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + assert_eq!(app.session.state.shape_tool.stroke_width, 9.0, "selected {kind} stroke"); + let [x, y, w, h] = app.auto.find("tools.Rectangle").unwrap().rect; + let at = egui::pos2(x + w / 2.0, y + h / 2.0); + for (time, pressed) in [(1.0, true), (1.05, false), (1.1, true), (1.15, false)] { + frame( + &mut app, + &ctx, + time, + vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ); + } + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2); + let layer = comp.layer(app.session.state.selected_layers[0]).unwrap(); + let (_, _, width) = effectcraft_engine::commands::shape_stroke::first_paint(layer, effectcraft_engine::time::Tick::ZERO); + assert_eq!(width, Some(9.0), "new {kind} stroke"); + let (_, stroke_kind) = effectcraft_engine::commands::shape_stroke::first_paint_kinds(layer, effectcraft_engine::time::Tick::ZERO); + assert_eq!(stroke_kind.id(), kind); + } + } } diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 8fee44a72..e9a99522e 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -124,6 +124,9 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool { if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue { return !app.session.is_rendering() && !app.session.project.render_queue.is_empty(); } + if e.command == "file.close" && super::invoke::no_params(&e.params) && app.ui.focused == PanelKind::RenderQueue { + return true; + } if e.command == "window.panel" { return true; } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index f1958fb5e..fbd4fa1fe 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -159,7 +159,7 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI }) } -fn no_params(p: &Value) -> bool { +pub(super) fn no_params(p: &Value) -> bool { p.as_object().is_none_or(|m| m.is_empty()) } @@ -216,12 +216,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } // Close follows panel focus too: with the Render Queue active, Cmd+W closes that panel and // leaves the active composition open (it used to close the composition next to it). - if id == "file.close" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::RenderQueue { + if id == "file.close" && no_params(¶ms) && app.ui.focused == PanelKind::RenderQueue { app.close_panel(PanelKind::RenderQueue); // The focus goes to a panel that is still there, so the next Close acts on the composition // instead of on the queue that is gone. - app.ui.focused = + let next = [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| app.ui.dock.is_visible(*p)).unwrap_or(PanelKind::Project); + app.show_panel(next); return Ok(Value::Null); } // Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 5af50f0fc..a043f67d1 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -437,6 +437,42 @@ fn cmd_w_in_the_render_queue_closes_the_queue_not_the_comp() { assert_eq!(app.session.state.open_comps.len(), 0, "a second Close acts on the composition"); } +#[test] +fn file_close_is_available_for_an_empty_render_queue_without_a_comp() { + for params in [Value::Null, json!({})] { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + assert!(app.session.active_comp().is_none()); + app.show_panel(PanelKind::RenderQueue); + assert!(menu_items(&app).iter().find(|item| item.id == "file.close").unwrap().enabled); + invoke(&mut app, &ctx, "file.close", params).unwrap(); + assert!(!app.ui.dock.is_open(PanelKind::RenderQueue)); + } +} + +#[test] +fn closing_the_render_queue_reveals_the_panel_receiving_focus() { + for maximized in [false, true] { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.close_panel(PanelKind::Timeline); + app.close_panel(PanelKind::Composition); + app.show_panel(PanelKind::EffectControls); + assert!(!app.ui.dock.is_visible(PanelKind::Project)); + app.show_panel(PanelKind::RenderQueue); + if maximized { + app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::RenderQueue, rect: [50.0, 60.0, 400.0, 200.0] }).unwrap(); + app.toggle_maximize(PanelKind::EffectControls); + app.ui.focused = PanelKind::RenderQueue; + assert!(app.ui.dock.is_floating(PanelKind::RenderQueue)); + } + invoke(&mut app, &ctx, "file.close", json!({})).unwrap(); + assert_eq!(app.ui.focused, PanelKind::Project); + assert!(app.ui.dock.is_visible(app.ui.focused), "focus must belong to a front tab"); + assert_eq!(app.ui.maximized, None, "the focused docked panel must be on screen"); + } +} + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help")); From b3887c226f93813d16873c2f706ae91086c36adc Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Thu, 29 Oct 2026 21:58:14 +0100 Subject: [PATCH 43/60] Graph Editor: keep close focus and locked selections consistent (#1342) --- crates/engine/src/cmd/anim.rs | 15 +++++++-- crates/engine/src/tests/delete_keys.rs | 28 ++++++++++++++++ crates/ui-egui/src/dock_ui.rs | 2 +- crates/ui-egui/src/menus/entries.rs | 2 +- crates/ui-egui/src/menus/invoke.rs | 3 +- crates/ui-egui/src/menus/tests.rs | 44 ++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs index 073262d0f..e2bbab62b 100644 --- a/crates/engine/src/cmd/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -55,6 +55,10 @@ fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool { a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len() } +fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec { + selected.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect() +} + /// Paste copied keyframes at the current time into the target layers' properties. fn paste(s: &mut Session, p: &Value) -> Result { let clip = s.state.key_clipboard.clone(); @@ -117,7 +121,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { } s.edit("Paste Keyframes", merge_p(p), |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let mut sel: Vec = st.selected_keys.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect(); + let mut sel = locked_selection(comp, &st.selected_keys); let retained = sel.len(); for (lid, uid, ci) in &plan { let Some(l) = comp.layer_mut(*lid) else { continue }; @@ -210,6 +214,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L }) .collect(); s.edit("Paste Keyframes", merge_p(p), |proj, st| { + let comp = proj.comp(cid).ok_or(EngineError::NoComp)?; + let mut selected = locked_selection(comp, &st.selected_keys); + let retained = selected.len(); let l = super::layer_mut(proj, cid, lid)?; let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?; let mut sel = vec![]; @@ -224,8 +231,10 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L // Re-timing keeps the keys' order, so the pasted keys are found again by index. let pasted: Vec = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect(); retime_roving(&mut pr.keys, pr.spatial); - st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect(); - Ok(json!(st.selected_keys.len())) + selected.extend(pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time })); + let count = selected.len().saturating_sub(retained); + st.selected_keys = selected; + Ok(json!(count)) }) } diff --git a/crates/engine/src/tests/delete_keys.rs b/crates/engine/src/tests/delete_keys.rs index a6ca08bed..ec6ec888e 100644 --- a/crates/engine/src/tests/delete_keys.rs +++ b/crates/engine/src/tests/delete_keys.rs @@ -181,6 +181,34 @@ fn key_paste_skips_locked_targets_without_changing_layer_pairing() { assert_eq!(s.project.to_json(), pasted); } +#[test] +fn motion_path_paste_preserves_locked_source_selection() { + for explicit in [false, true] { + let (mut s, source, _) = setup(); + s.execute("mask.new", json!({"layer": source, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap(); + s.execute("prop.toggleAnimation", json!({"layer": source, "path": "masks/#1/path"})).unwrap(); + let uid = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop("masks/#1/path").unwrap().uid; + s.execute("keys.select", json!({"keys": [{"layer": source, "prop": uid, "time": 0}]})).unwrap(); + s.execute("keys.copy", json!({})).unwrap(); + let locked_keys = s.state.selected_keys.clone(); + let target = s.execute("layer.newSolid", json!({"name": "Target", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": target, "path": "transform/position"})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [source], "switch": "locked", "value": true})).unwrap(); + s.state.selected_keys = locked_keys.clone(); + let params = if explicit { json!({"layer": target, "path": "transform/position"}) } else { json!({}) }; + let before = s.project.to_json(); + assert_eq!(s.execute("keys.paste", params).unwrap(), json!(3)); + assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k))); + assert_eq!(s.state.selected_keys.len(), 4); + assert_eq!(s.state.selected_keys.iter().filter(|k| k.layer == LayerId(target)).count(), 3); + let pasted = s.project.to_json(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), pasted); + } +} + #[test] fn key_transform_preserves_locked_keys_in_a_mixed_selection() { let (mut s, locked, locked_prop) = setup(); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 30710392a..750764b84 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -398,7 +398,7 @@ impl EffectcraftApp { /// A press in a panel (its body, tab or group header) focuses it; another Composition viewer /// also becomes the active one. - fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) { + pub(crate) fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) { self.ui.focused = p; panels::viewer_text::panel_clicked(self, ctx, p); if let Some(id) = panels::viewers::id_of(p) { diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index e9a99522e..cb3a0042b 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -125,7 +125,7 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool { return !app.session.is_rendering() && !app.session.project.render_queue.is_empty(); } if e.command == "file.close" && super::invoke::no_params(&e.params) && app.ui.focused == PanelKind::RenderQueue { - return true; + return !app.ui.dock.locked; } if e.command == "window.panel" { return true; diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index fbd4fa1fe..b3a29d66a 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -217,12 +217,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V // Close follows panel focus too: with the Render Queue active, Cmd+W closes that panel and // leaves the active composition open (it used to close the composition next to it). if id == "file.close" && no_params(¶ms) && app.ui.focused == PanelKind::RenderQueue { - app.close_panel(PanelKind::RenderQueue); + app.apply_dock(craft_ui::docking::Action::Close { panel: PanelKind::RenderQueue })?; // The focus goes to a panel that is still there, so the next Close acts on the composition // instead of on the queue that is gone. let next = [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| app.ui.dock.is_visible(*p)).unwrap_or(PanelKind::Project); app.show_panel(next); + app.focus_panel(ctx, next); return Ok(Value::Null); } // Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index a043f67d1..83ba78dc1 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -473,6 +473,50 @@ fn closing_the_render_queue_reveals_the_panel_receiving_focus() { } } +#[test] +fn queue_close_preserves_state_when_workspace_is_locked() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("comp.new", json!({"name": "A", "duration": 2})).unwrap(); + app.show_panel(PanelKind::RenderQueue); + app.ui.dock.locked = true; + let workspace = app.ui.dock.clone(); + let comp = app.session.active_comp_id(); + for _ in 0..2 { + assert!(invoke(&mut app, &ctx, "file.close", Value::Null).is_err()); + assert_eq!(app.ui.dock, workspace); + assert_eq!(app.ui.focused, PanelKind::RenderQueue); + assert_eq!(app.session.active_comp_id(), comp); + } + assert!(!menu_items(&app).iter().find(|item| item.id == "file.close").unwrap().enabled); + app.ui.dock.locked = false; + invoke(&mut app, &ctx, "file.close", Value::Null).unwrap(); + assert!(!app.ui.dock.is_open(PanelKind::RenderQueue)); +} + +#[test] +fn queue_close_preserves_viewer_command_target() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let a = app.session.execute("comp.new", json!({"name": "A", "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let b = app.session.execute("comp.new", json!({"name": "B", "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + app.show_panel(PanelKind::Viewer(1)); + app.ui.viewers.insert(0, Some(a)); + app.ui.viewers.insert(1, Some(b)); + app.ui.active_viewer = 1; + app.close_panel(PanelKind::Timeline); + app.show_panel(PanelKind::Composition); + app.show_panel(PanelKind::RenderQueue); + assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(b)); + invoke(&mut app, &ctx, "file.close", json!({})).unwrap(); + assert_eq!(app.ui.focused, PanelKind::Composition); + assert_eq!(app.ui.active_viewer, 0); + assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(a)); + invoke(&mut app, &ctx, "file.close", json!({})).unwrap(); + assert!(!app.session.state.open_comps.iter().any(|id| id.0 == a)); + assert!(app.session.state.open_comps.iter().any(|id| id.0 == b)); +} + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help")); From f93d336f9d7bc0dbd61822a4651c7dfd903f3b7c Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Thu, 29 Oct 2026 22:38:53 +0100 Subject: [PATCH 44/60] Graph Editor: protect selection tools and restore queue-close focus --- crates/engine/src/cmd/anim.rs | 2 +- crates/engine/src/cmd/anim_tools.rs | 5 +- crates/engine/src/cmd/animation.rs | 3 +- crates/engine/src/cmd/keys_more.rs | 2 + crates/engine/src/cmd/mask_interp.rs | 12 +++-- crates/engine/src/cmd/prop.rs | 9 ++++ crates/engine/src/tests/anim_tools.rs | 74 +++++++++++++++++++++++++++ crates/engine/src/tests/mask_warp.rs | 45 ++++++++++++++++ crates/ui-egui/src/app/frame.rs | 2 +- crates/ui-egui/src/menus/invoke.rs | 4 ++ crates/ui-egui/src/menus/tests.rs | 46 +++++++++++++++++ 11 files changed, 194 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs index e2bbab62b..1064ba021 100644 --- a/crates/engine/src/cmd/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -55,7 +55,7 @@ fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool { a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len() } -fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec { +pub(crate) fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec { selected.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect() } diff --git a/crates/engine/src/cmd/anim_tools.rs b/crates/engine/src/cmd/anim_tools.rs index 06e0d341b..33ee39ae2 100644 --- a/crates/engine/src/cmd/anim_tools.rs +++ b/crates/engine/src/cmd/anim_tools.rs @@ -213,7 +213,8 @@ fn sample_curve( fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec> { let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); - for k in &s.state.selected_keys { + let Some(comp) = s.active_comp() else { return g }; + for k in super::prop::editable_keys(comp, &s.state.selected_keys) { g.entry((k.layer, k.prop)).or_default().push(k.time); } for v in g.values_mut() { @@ -273,7 +274,7 @@ fn run_on_selection( s.edit(label, None, |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fr = comp.frame_rate; - let mut sel = vec![]; + let mut sel = super::anim::locked_selection(comp, &st.selected_keys); for ((lid, uid), times) in groups { let Some(l) = comp.layer_mut(lid) else { continue }; let lc = l.clone(); diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 1bdf3fed7..c781e9da1 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -209,7 +209,8 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result { /// Selected keys grouped by (layer, property). fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec> { let mut m: BTreeMap<(LayerId, Uid), Vec> = BTreeMap::new(); - for k in &s.state.selected_keys { + let Some(comp) = s.active_comp() else { return m }; + for k in super::prop::editable_keys(comp, &s.state.selected_keys) { m.entry((k.layer, k.prop)).or_default().push(k.time); } m diff --git a/crates/engine/src/cmd/keys_more.rs b/crates/engine/src/cmd/keys_more.rs index c7785d08c..4edef885a 100644 --- a/crates/engine/src/cmd/keys_more.rs +++ b/crates/engine/src/cmd/keys_more.rs @@ -37,6 +37,8 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result { return Err(bad("keys.label", "select keyframes first")); } let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let keys = super::prop::editable_keys(comp, &keys); let n = s.edit("Keyframe Label", None, |proj, _| { let mut n = 0; for k in &keys { diff --git a/crates/engine/src/cmd/mask_interp.rs b/crates/engine/src/cmd/mask_interp.rs index 982468c8e..bbcc9f99d 100644 --- a/crates/engine/src/cmd/mask_interp.rs +++ b/crates/engine/src/cmd/mask_interp.rs @@ -437,20 +437,22 @@ fn interpolate(s: &mut Session, p: &Value) -> Result { // The layer: given, else the layer of selected Mask Path keys, else the selection. let cid = super::comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let selected = super::prop::editable_keys(comp, &s.state.selected_keys); let lid = match p.get("layer") { Some(v) => super::resolve_layer(comp, v).ok_or_else(|| bad(CMD, format!("no layer {v}")))?, - None => match s.state.selected_keys.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) { + None => match selected.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) { Some(k) => k.layer, None => layer_p(s, p, CMD)?.1, }, }; let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + if !super::prop::key_layer_editable(comp, lid) { + return Err(bad(CMD, "the layer is locked")); + } let masks = layer.masks().ok_or_else(|| bad(CMD, "the layer has no masks"))?; let uid = match p.get("mask") { Some(_) => mask_uid(s, layer, p), - None => s - .state - .selected_keys + None => selected .iter() .filter(|k| k.layer == lid) .find_map(|k| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == k.prop)).map(|g| g.uid)) @@ -465,7 +467,7 @@ fn interpolate(s: &mut Session, p: &Value) -> Result { let ts: Vec = a.iter().filter_map(Value::as_f64).map(|t| layer.layer_time(comp.frame_rate.snap_nearest(Tick::from_seconds_f64(t)))).collect(); ts.into_iter().filter_map(|t| path_prop.keys.iter().find(|k| (k.time.0 - t.0).abs() <= comp.frame_duration().0 / 2).map(|k| k.time)).collect() } - None => s.state.selected_keys.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(), + None => selected.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(), }; times.sort(); times.dedup(); diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 0eff1dd09..b08021b66 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -883,6 +883,15 @@ fn select_keys(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// References that can be edited, including explicit key lists supplied by commands. +pub(crate) fn editable_keys(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec { + selected.iter().filter(|key| key_layer_editable(comp, key.layer)).copied().collect() +} + +pub(crate) fn key_layer_editable(comp: &effectcraft_project::Comp, layer: LayerId) -> bool { + comp.layer(layer).is_some_and(|layer| !layer.switches.locked) +} + /// Apply `f` to every selected key; keeps the selection pointing at moved keys. pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec, usize, &mut Tick) -> bool) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; diff --git a/crates/engine/src/tests/anim_tools.rs b/crates/engine/src/tests/anim_tools.rs index 06d194001..96b861972 100644 --- a/crates/engine/src/tests/anim_tools.rs +++ b/crates/engine/src/tests/anim_tools.rs @@ -9,6 +9,80 @@ fn select(s: &mut crate::Session, layer: u64, path: &str) { s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap(); } +fn selection_command_skips_locked_keys(command: &str, path: &str, values: [serde_json::Value; 3], mut params: serde_json::Value) { + let (mut s, locked) = setup(); + let unlocked = s.execute("layer.newSolid", json!({"name": "Editable", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + for layer in [locked, unlocked] { + animate(&mut s, layer, path, &[(0.0, values[0].clone()), (0.5, values[1].clone()), (1.0, values[2].clone())]); + } + let before = prop(&s, locked, path).keys; + let selected: Vec<_> = [locked, unlocked] + .into_iter() + .flat_map(|layer| { + let pr = prop(&s, layer, path); + pr.keys.into_iter().map(move |key| crate::KeyRef { layer: effectcraft_project::LayerId(layer), prop: pr.uid, time: key.time }) + }) + .collect(); + s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap(); + s.state.selected_keys = selected.clone(); + if params.get("keys").is_some() { + params["keys"] = serde_json::to_value(&selected).unwrap(); + } + s.execute_checked(command, params.clone()).unwrap(); + assert_eq!(prop(&s, locked, path).keys, before, "{command} changed a locked layer"); + let after = prop(&s, unlocked, path).keys; + assert_ne!(after, before, "{command} must still edit unlocked keys"); + let after_selection = s.state.selected_keys.clone(); + assert!(selected.iter().filter(|k| k.layer.0 == locked).all(|key| after_selection.contains(key))); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, unlocked, path).keys, before); + assert!(selected.iter().filter(|key| key.layer.0 == locked).all(|key| s.state.selected_keys.contains(key))); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(prop(&s, unlocked, path).keys, after); + assert_eq!(s.state.selected_keys, after_selection); + // All-locked input must preserve the selection and document as well. + s.state.selected_keys.retain(|k| k.layer.0 == locked); + if params.get("keys").is_some() { + params["keys"] = serde_json::to_value(&s.state.selected_keys).unwrap(); + } + let locked_selection = s.state.selected_keys.clone(); + let project = serde_json::to_value(&*s.project).unwrap(); + let _ = s.execute_checked(command, params); + assert_eq!(serde_json::to_value(&*s.project).unwrap(), project); + assert_eq!(s.state.selected_keys, locked_selection); +} + +#[test] +fn wiggler_preserves_locked_keys_in_mixed_selection() { + selection_command_skips_locked_keys( + "keys.wiggle", + "transform/opacity", + [json!(10), json!(90), json!(20)], + json!({"apply": "temporal", "frequency": 8, "magnitude": 10}), + ); +} + +#[test] +fn smoother_preserves_locked_keys_in_mixed_selection() { + selection_command_skips_locked_keys("keys.smooth", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"tolerance": 1})); +} + +#[test] +fn exponential_scale_preserves_locked_keys_in_mixed_selection() { + selection_command_skips_locked_keys( + "keys.exponentialScale", + "transform/scale", + [json!([100, 100, 100]), json!([150, 150, 150]), json!([400, 400, 400])], + json!({}), + ); +} + +#[test] +fn edit_label_preserves_locked_keys_in_mixed_selection() { + selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red"})); + selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red", "keys": true})); +} + #[test] fn wiggle_adds_keys_at_frequency_within_magnitude() { let (mut s, l) = setup(); diff --git a/crates/engine/src/tests/mask_warp.rs b/crates/engine/src/tests/mask_warp.rs index adcea44be..5387fe562 100644 --- a/crates/engine/src/tests/mask_warp.rs +++ b/crates/engine/src/tests/mask_warp.rs @@ -184,6 +184,51 @@ fn mask_interpolation_creates_matching_keys() { assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err()); } +#[test] +fn mask_interpolation_skips_locked_selection_and_rejects_explicit_locked_targets() { + let (mut s, locked, _) = setup(patch_frames(rotating_pose)); + let unlocked = effectcraft_project::LayerId(s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap()); + let a = ShapePath::rect([20.0, 20.0], 10.0, 10.0); + let b = ShapePath::rect([40.0, 30.0], 20.0, 20.0); + let cid = s.active_comp_id().unwrap(); + let mut selected = vec![]; + let mut masks = vec![]; + for layer in [locked, unlocked] { + let uid = new_mask(&mut s, layer, &a); + masks.push(uid); + s.edit("keys", None, |project, _| { + let pr = project.comp_mut(cid).unwrap().layer_mut(layer).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap(); + pr.keys = vec![Keyframe::new(frame_time(0), KV::Path(a.clone())), Keyframe::new(frame_time(20), KV::Path(b.clone()))]; + selected.extend(pr.keys.iter().map(|key| crate::KeyRef { layer, prop: pr.uid, time: key.time })); + Ok(()) + }) + .unwrap(); + } + s.execute("layer.setSwitch", json!({"layers": [locked.0], "switch": "locked", "value": true})).unwrap(); + s.state.selected_keys = selected.clone(); + let before = mask_path(&s, locked, masks[0]).keys; + let options = serde_json::to_value(&s.state.mask_interp).unwrap(); + let project = serde_json::to_value(&*s.project).unwrap(); + let error = s.execute_checked("mask.interpolate", json!({"layer": locked.0, "mask": masks[0], "times": [0.0, 0.8], "quality": 75})).unwrap_err(); + assert!(error.to_string().contains("locked"), "{error}"); + assert_eq!(serde_json::to_value(&*s.project).unwrap(), project); + assert_eq!(serde_json::to_value(&s.state.mask_interp).unwrap(), options); + assert_eq!(s.state.selected_keys, selected); + s.execute_checked("mask.interpolate", json!({})).unwrap(); + assert_eq!(mask_path(&s, locked, masks[0]).keys, before); + assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21); + assert_eq!(s.state.selected_keys, selected); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 2); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21); + s.state.selected_keys.retain(|key| key.layer == locked); + let selection = s.state.selected_keys.clone(); + assert!(s.execute_checked("mask.interpolate", json!({})).is_err()); + assert_eq!(mask_path(&s, locked, masks[0]).keys, before); + assert_eq!(s.state.selected_keys, selection); +} + // ---------------------------------------------------------------- Warp Stabilizer /// Camera offset on frame `f`: a slow pan plus jitter. diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index d41e0a9d0..a76e7dce1 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -90,7 +90,7 @@ impl EffectcraftApp { /// Apply the engine events drained this tick: open a comp, show a toast, open a URL, run a frontend command, /// purge frame caches or copy an image; command and clipboard failures go to `app.ui.status`. - pub(super) fn handle_events(&mut self, ctx: &egui::Context) { + pub(crate) fn handle_events(&mut self, ctx: &egui::Context) { for ev in self.session.drain_events() { match ev { effectcraft_engine::Event::OpenComp(c) => { diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b3a29d66a..446513f05 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -224,6 +224,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| app.ui.dock.is_visible(*p)).unwrap_or(PanelKind::Project); app.show_panel(next); app.focus_panel(ctx, next); + // Activating a passive viewer emits OpenComp, which raises Timeline. Finish that routing + // before restoring the destination panel, including when they share the same tab group. + app.handle_events(ctx); + app.show_panel(next); return Ok(Value::Null); } // Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 83ba78dc1..63fac9879 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -504,6 +504,12 @@ fn queue_close_preserves_viewer_command_target() { app.ui.viewers.insert(0, Some(a)); app.ui.viewers.insert(1, Some(b)); app.ui.active_viewer = 1; + app.apply_dock(craft_ui::docking::Action::Move { + panel: PanelKind::Timeline, + anchor: PanelKind::Composition, + placement: craft_ui::docking::Placement::Tab { before: None }, + }) + .unwrap(); app.close_panel(PanelKind::Timeline); app.show_panel(PanelKind::Composition); app.show_panel(PanelKind::RenderQueue); @@ -517,6 +523,46 @@ fn queue_close_preserves_viewer_command_target() { assert!(app.session.state.open_comps.iter().any(|id| id.0 == b)); } +#[test] +fn queue_close_keeps_passive_viewer_focus_across_frames() { + let mut session = effectcraft_engine::Session::default(); + let a = session.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let b = session.execute("comp.new", json!({"name": "B", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(3); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + app.show_panel(PanelKind::Viewer(1)); + app.ui.viewers.insert(0, Some(a)); + app.ui.viewers.insert(1, Some(b)); + app.ui.active_viewer = 1; + app.apply_dock(craft_ui::docking::Action::Move { + panel: PanelKind::Timeline, + anchor: PanelKind::Composition, + placement: craft_ui::docking::Placement::Tab { before: None }, + }) + .unwrap(); + app.close_panel(PanelKind::Timeline); + app.show_panel(PanelKind::Composition); + app.show_panel(PanelKind::RenderQueue); + invoke(app, &ctx, "file.close", Value::Null).unwrap(); + h.run_steps(4); + let app = h.state_mut(); + assert_eq!(app.ui.focused, PanelKind::Composition); + assert_eq!(app.ui.active_viewer, 0); + assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(a)); + assert!(app.ui.dock.is_visible(PanelKind::Composition)); + assert!(!app.ui.dock.is_open(PanelKind::RenderQueue)); + invoke(app, &ctx, "file.close", json!({})).unwrap(); + assert!(!app.session.state.open_comps.iter().any(|id| id.0 == a)); + assert!(app.session.state.open_comps.iter().any(|id| id.0 == b)); + // A later explicit Open still routes normally, rather than inheriting a deferred focus override. + invoke(app, &ctx, "comp.open", json!({"comp": b})).unwrap(); + h.run_steps(3); + assert!(h.state().ui.dock.is_visible(PanelKind::Timeline)); + assert_eq!(h.state().session.active_comp_id().map(|id| id.0), Some(b)); +} + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help")); From 86b357162ca8005ec1c08502c376147e5ab7a967 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 02:29:16 +0100 Subject: [PATCH 45/60] #1685: synchronize Polystar controls across animation edit routes --- crates/engine/src/cmd/animation.rs | 5 ++- crates/engine/src/cmd/prop.rs | 44 +++++++++++++++---- crates/engine/src/tests/viewer.rs | 69 ++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 3f9c195c2..58eaa060d 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -243,7 +243,7 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result { } let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let t = s.time(); - let n = s.edit("Add Keyframe", None, |proj, _| { + let n = s.edit("Add Keyframe", None, |proj, state| { let mut n = 0; for (lid, uid) in &targets { let l = layer_mut(proj, cid, *lid)?; @@ -259,6 +259,9 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result { pr.set_value_at(lt, v); } n += 1; + if pr.match_id == "type" { + super::prop::sync_polystar_property(proj, state, cid, *lid, *uid, t)?; + } } Ok(n) })?; diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 663429fb2..f1fcb5246 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -48,7 +48,6 @@ fn with_prop( let t = s.time_of(cid); let linear = s.prefs.general.default_spatial_linear; s.edit(label, merge, |proj, state| { - let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?; @@ -64,17 +63,32 @@ fn with_prop( } } if pr.match_id == "type" { - let removed = sync_polystar_type(l, uid, lt, &mut next)?; - if !removed.is_empty() { - state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop)); - state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop)); - } + sync_polystar_property(proj, state, cid, lid, uid, t)?; } - proj.next_id = next; Ok(r) }) } +/// Keep Polystar controls and their selection references in the property's edit transaction. +pub(super) fn sync_polystar_property( + proj: &mut effectcraft_project::Project, + state: &mut crate::EditorState, + cid: ItemId, + lid: LayerId, + uid: Uid, + time: Tick, +) -> Result<()> { + let mut next = proj.next_id; + let layer = layer_mut(proj, cid, lid)?; + let removed = sync_polystar_type(layer, uid, layer.layer_time(time), &mut next)?; + if !removed.is_empty() { + state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop)); + state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop)); + } + proj.next_id = next; + Ok(()) +} + /// A static Polygon has no inner controls; switching to Star creates them in schema order /// with AE's switch defaults (#1685). Animated/expression-driven types need the controls at /// every time, including when the current value is Polygon. @@ -661,12 +675,16 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { s.project.comp(*cid).and_then(|c| c.layer(*lid).and_then(|l| super::link::reference(c, l, l, *uid, true))).unwrap_or_else(|| "value".into()) }) .collect(); - let results = s.edit("Expression", merge_p(p), |proj, _| { + let times: Vec<_> = targets.iter().map(|(cid, _, _)| s.time_of(*cid)).collect(); + let results = s.edit("Expression", merge_p(p), |proj, state| { let mut out = vec![]; - for ((cid, lid, uid), def) in targets.iter().zip(&defs) { + for (((cid, lid, uid), def), time) in targets.iter().zip(&defs).zip(×) { let pr = layer_mut(proj, *cid, *lid)?.props.find_mut(*uid).ok_or_else(|| bad(C, "property vanished"))?; apply_expr(pr, &text, enabled, def); out.push(json!(pr.expr.as_ref().map(|e| e.text.clone()))); + if pr.match_id == "type" { + sync_polystar_property(proj, state, *cid, *lid, *uid, *time)?; + } } Ok(out) })?; @@ -1042,9 +1060,11 @@ fn move_keys(s: &mut Session, p: &Value) -> Result { fn delete_keys(s: &mut Session, _: &Value) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let sel = s.state.selected_keys.clone(); + let time = s.time(); s.edit("Delete Keyframes", None, |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut removed = 0usize; + let mut changed = std::collections::BTreeSet::new(); for k in &sel { if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop)) && let Some(i) = key_at(&pr.keys, k.time) @@ -1054,9 +1074,15 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result { } pr.keys.remove(i); removed += 1; + if pr.match_id == "type" { + changed.insert((k.layer, k.prop)); + } } } st.selected_keys.clear(); + for (layer, prop) in changed { + sync_polystar_property(proj, st, cid, layer, prop, time)?; + } // The keys actually removed: a selected key that is gone (or listed twice) isn't counted, // so a caller can tell that a delete changed nothing. Ok(json!(removed)) diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index 1ff12237e..67f08eda0 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -697,3 +697,72 @@ fn polystar_animated_type_keeps_the_inner_controls_needed_by_other_frames() { assert_eq!(star.get("type").unwrap().value_at(Tick::from_seconds_f64(1.0)), KV::Enum(1)); assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(27.0)); } + +#[test] +fn polystar_expression_routes_synchronize_controls_in_the_same_undo_step() { + for explicit in [false, true] { + let mut s = comp(); + let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap(); + let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone(); + let before = group(&s); + let uid = before.get("type").unwrap().uid; + s.state.selected_props = vec![(LayerId(lid), uid)]; + let params = if explicit { json!({"layer": lid, "prop": uid, "expression": "1"}) } else { json!({"expression": "1"}) }; + s.execute("prop.setExpression", params).unwrap(); + let animated = group(&s); + assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some()); + assert!(s.undo()); + assert_eq!(group(&s), before); + assert!(s.redo()); + assert_eq!(group(&s), animated); + let inner = animated.get("innerRadius").unwrap().uid; + s.state.selected_props = vec![(LayerId(lid), inner)]; + s.execute("prop.setExpression", json!({"layer": lid, "prop": uid, "expression": ""})).unwrap(); + assert!(group(&s).get("innerRadius").is_none()); + assert!(s.state.selected_props.is_empty()); + assert!(s.undo()); + assert_eq!(group(&s), animated); + assert!(s.redo()); + assert!(group(&s).get("innerRadius").is_none()); + } +} + +#[test] +fn polystar_animation_menu_adds_inner_controls_and_undoes() { + let mut s = comp(); + let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap(); + let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone(); + let before = group(&s); + s.state.selected_props = vec![(LayerId(lid), before.get("type").unwrap().uid)]; + s.execute("anim.addKeyframe", json!({})).unwrap(); + let animated = group(&s); + assert_eq!(animated.get("type").unwrap().keys.len(), 1); + assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some()); + assert!(s.undo()); + assert_eq!(group(&s), before); + assert!(s.redo()); + assert_eq!(group(&s), animated); +} + +#[test] +fn deleting_the_last_polystar_type_key_removes_stale_controls_and_selection() { + for command in ["keys.delete", "edit.clear"] { + let mut s = comp(); + let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap(); + let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone(); + let type_uid = group(&s).get("type").unwrap().uid; + s.execute("prop.addKey", json!({"layer": lid, "prop": type_uid, "time": 0, "value": 1})).unwrap(); + let animated = group(&s); + let inner = animated.get("innerRadius").unwrap().uid; + s.state.selected_props = vec![(LayerId(lid), inner)]; + s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: type_uid, time: Tick::ZERO }]; + s.execute(command, json!({})).unwrap(); + let polygon = group(&s); + assert!(polygon.get("innerRadius").is_none() && polygon.get("innerRoundness").is_none()); + assert!(s.state.selected_props.is_empty() && s.state.selected_keys.is_empty()); + assert!(s.undo()); + assert_eq!(group(&s), animated); + assert!(s.redo()); + assert_eq!(group(&s), polygon); + } +} From 088bedff45d6d13aa03928dd6f17d95fd81e772f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 02:58:46 +0100 Subject: [PATCH 46/60] #1685: preserve composition selection and resolve all expression targets --- crates/engine/src/cmd/prop.rs | 16 +++++++-- crates/engine/src/tests/viewer.rs | 55 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index f1fcb5246..406e7182d 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -82,8 +82,13 @@ pub(super) fn sync_polystar_property( let layer = layer_mut(proj, cid, lid)?; let removed = sync_polystar_type(layer, uid, layer.layer_time(time), &mut next)?; if !removed.is_empty() { - state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop)); - state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop)); + if state.active_comp == Some(cid) { + state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop)); + state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop)); + } else if let Some(selection) = state.comp_selections.get_mut(&cid) { + selection.props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop)); + selection.keys.retain(|key| key.layer != lid || !removed.contains(&key.prop)); + } } proj.next_id = next; Ok(()) @@ -678,14 +683,19 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { let times: Vec<_> = targets.iter().map(|(cid, _, _)| s.time_of(*cid)).collect(); let results = s.edit("Expression", merge_p(p), |proj, state| { let mut out = vec![]; + let mut changed_types = vec![]; for (((cid, lid, uid), def), time) in targets.iter().zip(&defs).zip(×) { let pr = layer_mut(proj, *cid, *lid)?.props.find_mut(*uid).ok_or_else(|| bad(C, "property vanished"))?; apply_expr(pr, &text, enabled, def); out.push(json!(pr.expr.as_ref().map(|e| e.text.clone()))); if pr.match_id == "type" { - sync_polystar_property(proj, state, *cid, *lid, *uid, *time)?; + changed_types.push((*cid, *lid, *uid, *time)); } } + // All selected targets must be edited before a Type change can remove inner controls. + for (cid, lid, uid, time) in changed_types { + sync_polystar_property(proj, state, cid, lid, uid, time)?; + } Ok(out) })?; let r = if explicit { results.into_iter().next().unwrap_or(Value::Null) } else { Value::Array(results) }; diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index 67f08eda0..abce91877 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -766,3 +766,58 @@ fn deleting_the_last_polystar_type_key_removes_stale_controls_and_selection() { assert_eq!(group(&s), polygon); } } + +#[test] +fn polystar_sync_cleans_the_edited_compositions_parked_selection() { + let mut s = comp(); + let first = s.active_comp_id().unwrap(); + let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap(); + let path = "contents/group/contents/star"; + let type_uid = layer(&s, lid).props.group(path).unwrap().get("type").unwrap().uid; + s.execute("prop.addKey", json!({"layer": lid, "prop": type_uid, "time": 0, "value": 1})).unwrap(); + let before = layer(&s, lid).props.group(path).unwrap().clone(); + let inner = before.get("innerRadius").unwrap().uid; + s.state.selected_props = vec![(LayerId(lid), inner)]; + s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner, time: Tick::ZERO }]; + s.execute("comp.new", json!({"width": 100, "height": 100, "duration": 2})).unwrap(); + let second = s.active_comp_id().unwrap(); + let other = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap(); + let other_prop = layer(&s, other).props.prop("transform/rotation").unwrap().uid; + s.execute("prop.addKey", json!({"layer": other, "prop": other_prop, "time": 0, "value": 15})).unwrap(); + s.state.selected_props = vec![(LayerId(other), other_prop)]; + s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(other), prop: other_prop, time: Tick::ZERO }]; + let live = (s.state.selected_props.clone(), s.state.selected_keys.clone()); + s.execute("prop.toggleAnimation", json!({"comp": first.0, "layer": lid, "prop": type_uid, "value": false})).unwrap(); + assert_eq!(s.active_comp_id(), Some(second)); + assert_eq!((s.state.selected_props.clone(), s.state.selected_keys.clone()), live); + let parked = s.state.comp_selections.get(&first).unwrap(); + assert!(parked.props.is_empty() && parked.keys.is_empty(), "the inactive composition must not keep removed control references"); + let after = s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap().clone(); + assert!(after.get("innerRadius").is_none()); + assert!(s.undo()); + assert_eq!(s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap(), &before); + assert!(s.redo()); + assert_eq!(s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap(), &after); +} + +#[test] +fn polystar_multi_property_expression_clear_resolves_targets_before_removing_controls() { + let mut s = comp(); + let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap(); + let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone(); + let type_uid = group(&s).get("type").unwrap().uid; + s.execute("prop.setExpression", json!({"layer": lid, "prop": type_uid, "expression": "1"})).unwrap(); + let inner = group(&s).get("innerRadius").unwrap().uid; + s.execute("prop.setExpression", json!({"layer": lid, "prop": inner, "expression": "37"})).unwrap(); + let before = group(&s); + s.state.selected_props = vec![(LayerId(lid), type_uid), (LayerId(lid), inner)]; + assert_eq!(s.execute("prop.setExpression", json!({"expression": ""})).unwrap(), json!([null, null])); + let after = group(&s); + assert!(after.get("type").unwrap().expr.is_none()); + assert!(after.get("innerRadius").is_none()); + assert_eq!(s.state.selected_props, vec![(LayerId(lid), type_uid)]); + assert!(s.undo()); + assert_eq!(group(&s), before); + assert!(s.redo()); + assert_eq!(group(&s), after); +} From f2d2aca6d99897bdc3fd12825fd05e8eb4da4307 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:14:07 +0000 Subject: [PATCH 47/60] i18n(ja): drop the space between Latin text and Japanese in 37 rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit About 94 rows write Latin text and numbers directly against Japanese ("360度", "EffectCraftについて", "メモリとCPU"); 37 rows put a space on one side ("メモリと CPU", "3D レンダラー", "{} 回使用"), and no row mixes the two. The spaced rows now follow the majority. "getartcraft.com の EffectCraft" keeps its spaces so the domain stays readable. Only Japanese values change; no key or English text does. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01K5NRZNWfaSLBWVzV8hivyS --- crates/ui-egui/src/i18n.rs | 8 ++--- crates/ui-egui/src/i18n/ui.rs | 66 +++++++++++++++++------------------ 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 14afe59cc..64c9642b0 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1907,7 +1907,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "シェイプレイヤー"), ("layer.newAdjustment", "Adjustment Layer", "調整レイヤー"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "コンテンツに応じた塗りつぶしレイヤー..."), - ("layer.newPrimitive", "3D Primitive...", "3D プリミティブ..."), + ("layer.newPrimitive", "3D Primitive...", "3Dプリミティブ..."), ("layer.new3dPrimitive", "Cube", "立方体"), ("layer.new3dPrimitive", "Sphere", "球体"), ("layer.new3dPrimitive", "Plane", "平面"), @@ -2206,9 +2206,9 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("view.zoomOut", "Zoom Out", "ズームアウト"), ("", "Resolution", "解像度"), ("view.res.full", "Full", "フル画質"), - ("view.res.half", "Half", "1/2 画質"), - ("view.res.third", "Third", "1/3 画質"), - ("view.res.quarter", "Quarter", "1/4 画質"), + ("view.res.half", "Half", "1/2画質"), + ("view.res.third", "Third", "1/3画質"), + ("view.res.quarter", "Quarter", "1/4画質"), ("view.res.custom", "Custom...", "カスタム..."), ("view.displayColorManagement", "Use Display Color Management", "ディスプレイカラーマネジメントを使用"), ("", "Simulate Output", "出力をシミュレート"), diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index c137204e6..d63b20cb5 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -395,7 +395,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Lighter", "明るく"), ("Link Selected Property", "選択したプロパティをリンク"), ("Location:", "位置:"), - ("Lock Aspect Ratio to {}", "縦横比を {} に固定"), + ("Lock Aspect Ratio to {}", "縦横比を{}に固定"), ("Lock to Zoom", "ズームにロック"), ("Locked: {} — {}", "ロック済み:{} — {}"), ("Log:", "ログ:"), @@ -463,8 +463,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("One-Node Camera", "1ノードカメラ"), ("Opacity:", "不透明度:"), ("Open Composition", "コンポジションを開く"), - ("used once", "1 回使用"), - ("used {} times", "{} 回使用"), + ("used once", "1回使用"), + ("used {} times", "{}回使用"), ("Compositions that use this item", "このアイテムを使用しているコンポジション"), ("Open Last Saved Project", "最後に保存したプロジェクトを開く"), ("Open New Composition", "新規コンポジションを開く"), @@ -657,16 +657,16 @@ const JAPANESE: &[(&str, &str)] = &[ ("Fit", "フィット"), ("Fit up to 100%", "100%までフィット"), ("Full", "フル画質"), - ("Half", "1/2 画質"), + ("Half", "1/2画質"), ("Only this layer uses this solid.", "この平面を使用するのはこのレイヤーだけです。"), ("Proportional Grid", "プロポーショナルグリッド"), - ("Quarter", "1/4 画質"), + ("Quarter", "1/4画質"), ("Region of Interest", "関心領域"), ("Reset Exposure", "露出をリセット"), ("Show Channel and Color Management Settings", "チャンネルとカラーマネジメント設定を表示"), ("Show Snapshot (hold)", "スナップショットを表示(長押し)"), ("Take Snapshot", "スナップショットを取得"), - ("Third", "1/3 画質"), + ("Third", "1/3画質"), ("Toggle Mask and Shape Path Visibility", "マスクとシェイプのパスを表示"), ("Toggle Transparency Grid", "透明グリッドを切り替え"), ("16 bits per channel", "チャンネルあたり16ビット"), @@ -680,7 +680,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Draft", "ドラフト"), ("Enable Per-character 3D", "文字ごとの3Dを有効化"), ("Fast Draft", "高速ドラフト"), - ("Fast Previews (Draft 3D, Faster Effects)", "高速プレビュー(ドラフト 3D、より高速なエフェクト)"), + ("Fast Previews (Draft 3D, Faster Effects)", "高速プレビュー(ドラフト3D、より高速なエフェクト)"), ("Green", "緑"), ("Import Image Sequence", "画像シーケンスを読み込み"), ("Import PDF / Illustrator File", "PDF / Illustratorファイルを読み込み"), @@ -693,7 +693,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Off (Final Quality)", "オフ(最終品質)"), ("OpenGL (compatibility)", "OpenGL(互換)"), ("Opus Bitrate: {} kbps", "Opusビットレート:{} kbps"), - ("RGB Straight", "RGB ストレート"), + ("RGB Straight", "RGBストレート"), ("Red", "赤"), ("Tone map", "トーンマップ"), ("Use In-Window Menu Bar on macOS", "macOSでウィンドウ内メニューバーを使用"), @@ -793,10 +793,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Faster", "高速"), ("More Accurate", "精度を優先"), ("Display Color Space", "表示色空間"), - ("GPU Information...", "GPU 情報..."), + ("GPU Information...", "GPU情報..."), ("Appearance", "外観"), ("Brightness", "明るさ"), - ("UI Scale", "UI スケール"), + ("UI Scale", "UIスケール"), ("Labels and Colors", "ラベルとカラー"), ("Use Label Color for Layer Handles and Paths", "レイヤーハンドルとパスにラベルカラーを使用"), ("Use Label Color for Related Tabs", "ラベルカラーを関連するタブに使用"), @@ -863,8 +863,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Media Cache", "メディアキャッシュ"), ("Database and Cache Folder", "データベースとキャッシュのフォルダー"), ("Conformed Audio Folder", "適合オーディオフォルダー"), - ("Memory & CPU", "メモリと CPU"), - ("RAM Reserved for Other Applications", "他のアプリケーション用に確保する RAM"), + ("Memory & CPU", "メモリとCPU"), + ("RAM Reserved for Other Applications", "他のアプリケーション用に確保するRAM"), ("Cache Budgets", "キャッシュの割り当て"), ("Layer Cache", "レイヤーキャッシュ"), ("Footage Frame Cache", "フッテージフレームキャッシュ"), @@ -878,7 +878,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Video Output During Playback", "再生中のビデオ出力"), ("Mirror on Computer Monitor", "コンピューターモニターにミラーリング"), ("Disable Video Output When in Background", "バックグラウンド動作時のビデオ出力を無効にする"), - ("Default 3D Renderer", "既定の 3D レンダラー"), + ("Default 3D Renderer", "既定の3Dレンダラー"), ("Show 3D Reference Axes", "3D参照軸を表示"), ("Extended Viewer", "拡張ビューアー"), ("Realtime Shadows in Draft", "ドラフトのリアルタイムシャドウ"), @@ -924,11 +924,11 @@ const JAPANESE: &[(&str, &str)] = &[ ("Speed (%)", "速度 (%)"), ("Use Draft Deformation", "下書き変形を使用"), ("Show Mesh", "メッシュを表示"), - ("Scale X (%)", "X 方向の拡大率 (%)"), - ("Scale Y (%)", "Y 方向の拡大率 (%)"), + ("Scale X (%)", "X方向の拡大率 (%)"), + ("Scale Y (%)", "Y方向の拡大率 (%)"), ("Rotation (degrees)", "回転 (度)"), - ("Move X", "X 移動"), - ("Move Y", "Y 移動"), + ("Move X", "X移動"), + ("Move Y", "Y移動"), ("Position (pixels)", "位置 (ピクセル)"), ("Position (percent of the composition)", "位置 (コンポジションに対する割合)"), ("Use Dimensions From", "使用するサイズの基準"), @@ -943,7 +943,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Pixel Aspect Ratio", "ピクセル縦横比"), ("Loop (times)", "ループ (回)"), ("Assign Profile", "プロファイルを割り当て"), - ("Preserve RGB", "RGB を保持"), + ("Preserve RGB", "RGBを保持"), ("Interpret As Linear Light", "リニア光として解釈"), ("Start Timecode", "開始タイムコード"), ("Override Start", "開始値を上書き"), @@ -952,7 +952,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Working space", "作業用スペース"), ("Working gamma", "作業用ガンマ"), ("Linearize working space", "作業用スペースをリニア化"), - ("Blend colors using 1.0 gamma", "1.0 ガンマで色を混合"), + ("Blend colors using 1.0 gamma", "1.0ガンマで色を混合"), ("Output color space", "出力カラースペース"), ("Video rendering and effects", "ビデオのレンダリングとエフェクト"), ("Time Span", "時間範囲"), @@ -964,7 +964,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Apply To New Layer", "新しいレイヤーに適用"), ("Sensitivity threshold (0–1, lower finds more)", "感度のしきい値 (0~1、低いほど多く検出)"), ("Time field (empty: automatic)", "時間フィールド (空欄: 自動)"), - ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO 日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"), ("First sample at (comp seconds)", "最初のサンプル位置 (コンポジション秒)"), ("Frame rate", "フレームレート"), ("Color (#rrggbb)", "カラー (#rrggbb)"), @@ -1057,12 +1057,12 @@ const JAPANESE: &[(&str, &str)] = &[ ("Mask Interpolation", "マスク補間"), ("Script Console", "スクリプトコンソール"), ("Essential Graphics", "エッセンシャルグラフィックス"), - ("ScriptUI Panel", "ScriptUI パネル"), - ("Lumetri Scopes", "Lumetri スコープ"), + ("ScriptUI Panel", "ScriptUIパネル"), + ("Lumetri Scopes", "Lumetriスコープ"), ("Media Browser", "メディアブラウザー"), ("Content-Aware Fill", "コンテンツに応じた塗り"), ("Create Nulls From Paths", "パスからヌルを作成"), - ("VR Comp Editor", "VR コンポジションエディター"), + ("VR Comp Editor", "VRコンポジションエディター"), ("Media Duration", "メディアの長さ"), ("Frame Rate", "フレームレート"), ("File Path", "ファイルパス"), @@ -1074,7 +1074,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Next Frame", "次のフレーム"), ("Last Frame", "最後のフレーム"), ("Name (A–Z)", "名前 (A–Z)"), - ("Merged PRs", "マージ済み PR"), + ("Merged PRs", "マージ済みPR"), ("Lines added", "追加行数"), ("Lines deleted", "削除行数"), ("Line delta", "行数の差分"), @@ -1084,9 +1084,9 @@ const JAPANESE: &[(&str, &str)] = &[ ("Last commit", "最後のコミット"), ("Display name", "表示名"), ("Real name", "本名"), - ("ArtCraft website", "ArtCraft のウェブサイト"), - ("Source code on GitHub", "GitHub のソースコード"), - ("3D Renderer", "3D レンダラー"), + ("ArtCraft website", "ArtCraftのウェブサイト"), + ("Source code on GitHub", "GitHubのソースコード"), + ("3D Renderer", "3Dレンダラー"), ("Preset Name", "プリセット名"), ("Save Preset…", "プリセットを保存…"), ("Delete Preset", "プリセットを削除"), @@ -1098,9 +1098,9 @@ const JAPANESE: &[(&str, &str)] = &[ ("Shutter Phase", "シャッター位相"), ("Samples Per Frame", "フレームあたりのサンプル数"), ("Adaptive Sample Limit", "適応サンプル数の上限"), - ("X and Y", "X と Y"), - ("X only", "X のみ"), - ("Y only", "Y のみ"), + ("X and Y", "XとY"), + ("X only", "Xのみ"), + ("Y only", "Yのみ"), ("Track Camera", "カメラをトラッキング"), ("Warp Stabilizer", "ワープスタビライザー"), ("Track Motion", "モーションをトラッキング"), @@ -1176,8 +1176,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Show Animation Presets", "アニメーションプリセットを表示"), (" Refresh List", " リストを更新"), (" Clear Recently Used", " 最近使用した項目を消去"), - ("Show 32 bpc Effects Only", "32 bpc のエフェクトのみ表示"), - ("Show GPU Accelerated Effects Only", "GPU アクセラレーションのエフェクトのみ表示"), + ("Show 32 bpc Effects Only", "32 bpcのエフェクトのみ表示"), + ("Show GPU Accelerated Effects Only", "GPUアクセラレーションのエフェクトのみ表示"), ("Show Effects for All Color Depths", "すべての色深度のエフェクトを表示"), ("* Animation Presets", "* アニメーションプリセット"), ("Select a layer to apply to", "適用するレイヤーを選択してください"), @@ -1206,7 +1206,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Adjust Exposure", "露出を調整"), ("Snapshot taken", "スナップショットを撮影しました"), ("Fast Previews", "高速プレビュー"), - ("3D View", "3D ビュー"), + ("3D View", "3Dビュー"), ("Current time", "現在の時間"), ("Layer Transform", "レイヤーのトランスフォーム"), ("Reset Transform", "トランスフォームをリセット"), From ad6bc0c19b256137ca3d68e1f6f57a6b2b69a4ea Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 09:19:17 +0700 Subject: [PATCH 48/60] Automation: a confined renderQueue.add without output needs a saved project (#2479) On a project that was never saved, renderQueue.add without output queued an item whose default output was the home folder, outside every automation root (rendering it was refused, but the queue held a path the agent may not write). While file access is confined, such an add is now refused with a hint to give output inside a write root; a saved project's default output (beside it) is checked when the queue renders, as before. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/backend.rs | 2 +- crates/automation/src/confine.rs | 14 ++++++++++++++ crates/automation/src/tests.rs | 6 ++++++ docs/mcp.md | 4 +++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index c3947c876..8f7931239 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -64,7 +64,7 @@ impl Backend { let params = if params.is_null() { json!({}) } else { params }; if let Some(c) = self.confinement.clone() { let lookup = match c.check_command(id, ¶ms)? { - Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)), + Some(l @ (crate::confine::Lookup::ProjectFile | crate::confine::Lookup::QueueDefault)) => Some((l, self.run("project.summary", json!({}))?)), Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)), None => None, }; diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 06b83a880..9b126bdcb 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -128,6 +128,9 @@ pub enum Lookup { ProjectFile, /// Every output of the queued Render Queue items (`renderQueue.list`). RenderOutputs, + /// Whether the open project has a file (`project.summary` → `path`): a Render Queue item + /// added without `output` writes beside it, or to the home folder before the first save. + QueueDefault, } impl Confinement { @@ -239,6 +242,10 @@ impl Confinement { if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given { return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined"))); } + // Without `output`, an unsaved project's item would write to the home folder (#2479). + if id == "renderQueue.add" && !given { + return Ok(Some(Lookup::QueueDefault)); + } if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs })); } @@ -253,6 +260,13 @@ impl Confinement { Some(p) => self.check(p, Access::Write).map(drop), None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())), }, + // A saved project's default output sits beside it, and rendering checks it then. + Lookup::QueueDefault => match found.get("path").and_then(Value::as_str) { + Some(_) => Ok(()), + None => Err(Error::BadArgs( + "`renderQueue.add`: the project has no file yet, so the default output would go to the home folder: give `output` inside an automation write root".into(), + )), + }, Lookup::RenderOutputs => { let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 1121c5d91..c1e346d9e 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1372,6 +1372,12 @@ fn automation_roots_confine_file_access() { assert!(err, "{name} {args} should be refused: {c:?}"); c[0]["text"].as_str().unwrap_or_default().to_string() }; + // #2479: an unsaved project's queue item without `output` would write to the home folder. + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Unsaved", "width": 64, "height": 64}})); + let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved"}})); + assert!(msg.contains("home folder") && msg.contains("give `output`"), "{msg}"); + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved", "output": p(&write, "unsaved.mp4")}})); + call_json(&mut s, "execute_command", json!({"command": "renderQueue.remove"})); // Reading: inside a read root, not outside. call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")})); let msg = denied(&mut s, "open_project", json!({"path": p(&outside, "free.ecproj")})); diff --git a/docs/mcp.md b/docs/mcp.md index a9f27b959..7685a66e2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -11,7 +11,9 @@ written only under a write root; with no write root nothing is written. Every en (`execute_command`, `command_run`, each `batch` step) is checked before it runs: its file parameters must be absolute paths inside the roots (symbolic links resolved, no `..`), and so must any other absolute path in its parameters. Save, Increment and Save and rendering the Render -Queue are checked against the files they write (the open project's, the queued outputs). Scripts +Queue are checked against the files they write (the open project's, the queued outputs); +`renderQueue.add` without `output` needs a saved project, since an unsaved one's default output +would go to the home folder (#2479). Scripts (`run_script`, `file.runScript`…), Execute File, Reveal in Finder, `$N` references to file paths in a batch, and the bridge's UI-driving tools (`ui_click`, `ui_key`, `ui_type`, `ui_drag`, `ui_set`, `script_ui`, `control`) are refused while confined. Without the flags nothing changes. From 38bf64ad9f3fd25b493c5bb2476a250af31fde48 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 09:23:36 +0700 Subject: [PATCH 49/60] Tests: the Timeline's text Animate and Add menus add animators and properties (#2513) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/ui_timeline_shapes.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index ae1e5a17f..2f05e1b99 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -492,3 +492,35 @@ fn shift_click_selects_several_effects_for_a_preset() { shift_click(&mut h, uids[1]); assert_eq!(selected(&h), [uids[0]], "a second Shift-click takes it out"); } + +/// #2513: the Text group's Animate menu adds Animator 1 with the chosen property (and a Range +/// Selector), and the animator's Add menu adds another property to it. +#[test] +fn text_animate_menu_adds_an_animator_with_the_property() { + let mut s = session(); + let l = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + let text = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("text").unwrap().uid; + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(text); + open_layer(&mut h, l); + click(&mut h, &format!("timeline.group.{text}.animate")); + let entry = h.get_by_label("Position").rect(); + click_at(&mut h, entry.center()); + let animators = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("text").unwrap().sub("animators").cloned() + }; + let anims = animators(&h).expect("an Animators group"); + let a = anims.groups().next().expect("Animator 1"); + let has = |a: &effectcraft_engine::project::PropGroup, m: &str| a.sub("properties").is_some_and(|p| p.children.iter().any(|c| c.match_id() == m)); + assert!(has(a, "position"), "Position was added: {:?}", a.sub("properties")); + assert!(a.sub("selectors").is_some_and(|s| !s.children.is_empty()), "with a Range Selector"); + // The animator's Add menu adds a property to it. + h.state_mut().ui.timeline.open_groups.insert(anims.uid); + h.state_mut().ui.timeline.open_groups.insert(a.uid); + h.run_steps(2); + click(&mut h, &format!("timeline.group.{}.add", a.uid)); + let entry = h.get_by_label("Property: Opacity").rect(); + click_at(&mut h, entry.center()); + let a = animators(&h).unwrap().groups().next().unwrap().clone(); + assert!(has(&a, "opacity"), "Opacity was added: {:?}", a.sub("properties")); +} From 2883ae9e57be27c4ebfba5987cc4b2ad5363b6e7 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 03:27:14 +0100 Subject: [PATCH 50/60] #1685: discard removed property selections after undo --- crates/engine/src/session/comps.rs | 13 ++++++---- crates/engine/src/tests/viewer.rs | 39 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index ed3810e79..4fda6628c 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -28,6 +28,9 @@ impl Session { /// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id /// counts only inside the comp that owns it, so the active comp never sees another comp's layers. + /// Validate property UIDs too: undo can remove a control and rewind the allocator, so keeping + /// its selection could silently select a different property when the UID is reused. Group + /// selections remain valid even though groups are not leaf properties. pub fn sanitize_state(&mut self) { let p = self.project.clone(); self.state.open_comps.retain(|c| p.comp(*c).is_some()); @@ -35,8 +38,8 @@ impl Session { let Some(c) = p.comp(*cid) else { return false }; let ids: Vec = c.layers.iter().map(|l| l.id).collect(); sel.layers.retain(|l| ids.contains(l)); - sel.props.retain(|(l, _)| ids.contains(l)); - sel.keys.retain(|k| ids.contains(&k.layer)); + sel.props.retain(|(lid, uid)| c.layer(*lid).is_some_and(|l| l.props.find(*uid).is_some() || l.props.find_group(*uid).is_some())); + sel.keys.retain(|k| c.layer(k.layer).is_some_and(|l| l.props.find(k.prop).is_some())); sel.vertices.retain(|v| ids.contains(&v.layer)); !sel.is_empty() }); @@ -49,11 +52,11 @@ impl Session { self.recall_selection(id); } } - if let Some(c) = self.active_comp() { + if let Some(c) = self.state.active_comp.and_then(|cid| p.comp(cid)) { let ids: Vec = c.layers.iter().map(|l| l.id).collect(); self.state.selected_layers.retain(|l| ids.contains(l)); - self.state.selected_props.retain(|(l, _)| ids.contains(l)); - self.state.selected_keys.retain(|k| ids.contains(&k.layer)); + self.state.selected_props.retain(|(lid, uid)| c.layer(*lid).is_some_and(|l| l.props.find(*uid).is_some() || l.props.find_group(*uid).is_some())); + self.state.selected_keys.retain(|k| c.layer(k.layer).is_some_and(|l| l.props.find(k.prop).is_some())); self.state.selected_vertices.retain(|v| ids.contains(&v.layer)); } if let Some((l, t)) = self.state.current_track diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index abce91877..ef2bd8acc 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -821,3 +821,42 @@ fn polystar_multi_property_expression_clear_resolves_targets_before_removing_con assert!(s.redo()); assert_eq!(group(&s), after); } + +/// Undo must discard removed controls before their UIDs can name newly allocated properties. +#[test] +fn polystar_undo_sanitizes_live_and_parked_property_selections_before_uid_reuse() { + for parked in [false, true] { + let mut s = comp(); + let cid = s.active_comp_id().unwrap(); + let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap(); + let polygon = layer(&s, lid).props.group("contents/group/contents/star").unwrap().clone(); + let type_uid = polygon.get("type").unwrap().uid; + s.execute("comp.new", json!({"width": 100, "height": 100, "duration": 2})).unwrap(); + let other = s.active_comp_id().unwrap(); + s.activate_comp(cid); + s.state.selected_props = vec![(LayerId(lid), type_uid)]; + s.execute("anim.addKeyframe", json!({})).unwrap(); + let inner = layer(&s, lid).props.group("contents/group/contents/star").unwrap().get("innerRadius").unwrap().uid; + let valid = vec![(LayerId(lid), type_uid), (LayerId(lid), polygon.uid)]; + s.state.selected_props = valid.iter().copied().chain([(LayerId(lid), inner)]).collect(); + s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner, time: Tick::ZERO }]; + if parked { + s.activate_comp(other); + } + assert!(s.undo()); + let (props, keys) = if parked { + let selection = s.state.comp_selections.get(&cid).unwrap(); + (&selection.props, &selection.keys) + } else { + (&s.state.selected_props, &s.state.selected_keys) + }; + assert_eq!(props, &valid, "remove stale leaves while preserving property and group selections"); + assert!(keys.is_empty()); + s.activate_comp(cid); + s.execute("prop.separateDimensions", json!({"layer": lid, "value": true})).unwrap(); + let position_z = layer(&s, lid).props.prop("transform/positionZ").unwrap().clone(); + assert_eq!(position_z.uid, inner, "the removed control's UID was actually reused"); + s.execute("prop.setExpression", json!({"expression": "1"})).unwrap(); + assert_eq!(layer(&s, lid).props.prop("transform/positionZ").unwrap(), &position_z, "the reused UID must not become an implicit expression target"); + } +} From c51b4618604fde69fb46fdfa358615c35f95b6ca Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 03:52:38 +0100 Subject: [PATCH 51/60] #1284: drop superseded shape tool double-click changes --- crates/engine/src/cmd/shape_tool.rs | 6 +- crates/engine/src/tests/session.rs | 12 --- crates/ui-egui/src/header.rs | 122 ---------------------------- crates/ui-egui/src/panels/viewer.rs | 2 +- 4 files changed, 3 insertions(+), 139 deletions(-) diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index 3db347e60..e1f3607b1 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -318,9 +318,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { }; let pos = position_p(p, c)?.unwrap_or([comp.width as f64 / 2.0, comp.height as f64 / 2.0]); let paint = paint_p(p, &s.state.shape_tool, c)?; - // `newLayer` makes a layer of its own even with a shape layer selected (a shape that fills the - // comp for a background or matte, whatever is selected). - let target = if b_p(p, "newLayer") == Some(true) { None } else { draw_target(s, &comp, p, c)? }; + let target = draw_target(s, &comp, p, c)?; // In the target layer's space: the centre maps through the layer's transform, the size by // its scale. let (centre, size) = match target.and_then(|l| comp.layer(l)) { @@ -376,7 +374,7 @@ pub fn specs() -> Vec { "Shape Tool", [], None, - "{layer?, newLayer? (a new shape layer even with a shape layer selected), kind?: rect|rounded|ellipse|polygon|star, size? [w,h], position? [x,y] (the centre), space?: comp|layer, name? (a new layer's), fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions)} → {layer, group}", + "{layer?, kind?: rect|rounded|ellipse|polygon|star, size? [w,h], position? [x,y] (the centre), space?: comp|layer, name? (a new layer's), fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions)} → {layer, group}", has_comp, new_shape ), diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index e0f3e9cd8..b5d1cbb5f 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -246,18 +246,6 @@ fn text_shape_mask_matte_parent() { assert_eq!(info["layers"].as_array().unwrap().len(), 2); } -/// A shape layer the size of the comp and placed at its default position covers the frame: the -/// Shape Tool's double-click (#1284) makes one this way, for backgrounds, mattes and overlays. -#[test] -fn a_comp_sized_shape_layer_sits_on_the_comp_centre() { - let mut s = Session::default(); - s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); - let sh = s.execute("layer.newShape", json!({"kind": "rect", "size": [400, 300], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); - let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone(); - let pos = l.props.prop("transform/position").unwrap().value.clone(); - assert!(matches!(pos, effectcraft_keyframe::Value::Vec3(p) if (p[0] - 200.0).abs() < 1e-9 && (p[1] - 150.0).abs() < 1e-9), "{pos:?}"); -} - /// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn /// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include /// an empty Path and a Gradient Stroke that render. diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 2c14e188b..3eee89453 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -57,22 +57,6 @@ fn brand_and_home(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter x + 6.0 } -/// The `shape.newShape` parameters for a shape that fills a `w` × `h` composition with `tool`: a new -/// layer, painted with the Tools bar's Fill and Stroke. A polygon or star is round, so it takes the -/// shorter side; the other kinds take the whole frame. -pub(crate) fn comp_sized_shape_params(tool: Tool, w: u32, h: u32) -> serde_json::Value { - let kind = crate::panels::viewer::shape_kind(tool); - let (w, h) = (f64::from(w), f64::from(h)); - let size = if matches!(kind, "polygon" | "star") { [w.min(h); 2] } else { [w, h] }; - json!({"kind": kind, "size": size, "position": [w / 2.0, h / 2.0], "newLayer": true}) -} - -/// Make the shape layer that fills the composition for a double-clicked shape `tool`. -fn comp_sized_shape(app: &mut EffectcraftApp, ctx: &egui::Context, tool: Tool) -> Result { - let (w, h) = app.session.active_comp().map(|c| (c.width, c.height)).ok_or("no composition is open")?; - crate::menus::invoke(app, ctx, "shape.newShape", comp_sized_shape_params(tool, w, h)) -} - /// The tool slots (with group separators after camera tools and pan-behind); returns the x after them. fn tool_slots(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { let t = app.tokens; @@ -126,14 +110,6 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: { app.ui.status = e; } - // Double-click a shape tool: a shape layer filling the composition, centred, for - // backgrounds, mattes and overlays (as After Effects does). - if cur.is_shape() - && resp.double_clicked() - && let Err(e) = comp_sized_shape(app, ui.ctx(), cur) - { - app.ui.status = e; - } if slot.len() > 1 { slot_flyout(app, ui, si, slot, &resp, held_id); } @@ -858,102 +834,4 @@ mod tests { frame(&mut app, &ctx, 2.1, vec![]); assert!(!app.auto.elements.iter().any(|w| w.id == "tools.select.TypeVertical")); } - - /// A polygon or star fills the shorter side; the other kinds fill the frame; all make a new layer. - #[test] - fn a_comp_sized_shape_fits_the_frame_for_every_kind() { - for (tool, size) in [ - (Tool::Rectangle, [1920.0, 1080.0]), - (Tool::RoundedRect, [1920.0, 1080.0]), - (Tool::Ellipse, [1920.0, 1080.0]), - (Tool::Polygon, [1080.0, 1080.0]), - (Tool::Star, [1080.0, 1080.0]), - ] { - let p = comp_sized_shape_params(tool, 1920, 1080); - assert_eq!(p["size"], json!(size), "{tool:?}"); - assert_eq!(p["position"], json!([960.0, 540.0])); - assert_eq!(p["newLayer"], json!(true)); - } - } - - /// The double-click shape takes the Tools bar's Fill, and is a new layer even with a shape layer - /// selected (not a group in that layer). - #[test] - fn the_double_clicked_shape_uses_the_toolbar_paint_on_a_new_layer() { - let mut s = effectcraft_engine::Session::default(); - s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); - let first = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); - s.execute("shape.fillStroke", json!({"layers": [first], "fill": "#00ff00", "stroke": false})).unwrap(); - s.execute("shape.toolOptions", json!({"fill": "#00ff00", "stroke": false})).unwrap(); - let mut app = EffectcraftApp::new(s); - let ctx = egui::Context::default(); - assert_eq!(app.session.state.selected_layers.first().map(|l| l.0), Some(first), "a shape layer is selected"); - crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); - frame(&mut app, &ctx, 0.0, vec![]); - frame(&mut app, &ctx, 0.1, vec![]); - assert_eq!(app.session.state.shape_tool.fill.color, [0.0, 1.0, 0.0], "the visible toolbar paint"); - let [x, y, w, h] = app.auto.find("tools.Rectangle").unwrap().rect; - let at = egui::pos2(x + w / 2.0, y + h / 2.0); - let pointer = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(true)]); - frame(&mut app, &ctx, 1.05, vec![pointer(false)]); - assert_eq!(app.session.active_comp().unwrap().layers.len(), 1, "a single click only selects the tool"); - // A separate double-click must route through the toolbar response, not just its helper. - for (time, pressed) in [(2.0, true), (2.05, false), (2.1, true), (2.15, false)] { - frame(&mut app, &ctx, time, vec![pointer(pressed)]); - } - let comp = app.session.active_comp().unwrap(); - assert_eq!(comp.layers.len(), 2, "a new layer, not a group in the selected one"); - let layer = comp.layers.iter().find(|l| l.id.0 != first).unwrap(); - assert_eq!(layer.props.prop("transform/position").unwrap().value.components(), vec![200.0, 150.0, 0.0]); - let mut fill = None; - let mut size = None; - layer.props.walk("", &mut |_, pr| { - if pr.name == "Size" { - size = Some(pr.value.clone()); - } - if fill.is_none() && matches!(pr.value, effectcraft_engine::keyframe::Value::Color(_)) { - fill = Some(pr.value.clone()); - } - }); - let fill = fill.expect("a fill colour"); - assert_eq!(size, Some(effectcraft_engine::keyframe::Value::Vec2([400.0, 300.0]))); - assert_eq!(fill, effectcraft_engine::keyframe::Value::Color([0.0, 1.0, 0.0, 1.0]), "the Tools bar's Fill, not the default blue"); - } - - #[test] - fn double_clicked_shapes_keep_the_selected_gradient_stroke_width() { - for kind in ["solid", "linear", "radial"] { - let mut s = effectcraft_engine::Session::default(); - s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); - s.execute("layer.newShape", json!({"kind": "rect"})).unwrap(); - s.execute("shape.fillStroke", json!({"fill": false, "strokeType": kind, "strokeWidth": 9})).unwrap(); - let mut app = EffectcraftApp::new(s); - let ctx = egui::Context::default(); - crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); - frame(&mut app, &ctx, 0.0, vec![]); - frame(&mut app, &ctx, 0.1, vec![]); - assert_eq!(app.session.state.shape_tool.stroke_width, 9.0, "selected {kind} stroke"); - let [x, y, w, h] = app.auto.find("tools.Rectangle").unwrap().rect; - let at = egui::pos2(x + w / 2.0, y + h / 2.0); - for (time, pressed) in [(1.0, true), (1.05, false), (1.1, true), (1.15, false)] { - frame( - &mut app, - &ctx, - time, - vec![ - egui::Event::PointerMoved(at), - egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, - ], - ); - } - let comp = app.session.active_comp().unwrap(); - assert_eq!(comp.layers.len(), 2); - let layer = comp.layer(app.session.state.selected_layers[0]).unwrap(); - let (_, _, width) = effectcraft_engine::commands::shape_stroke::first_paint(layer, effectcraft_engine::time::Tick::ZERO); - assert_eq!(width, Some(9.0), "new {kind} stroke"); - let (_, stroke_kind) = effectcraft_engine::commands::shape_stroke::first_paint_kinds(layer, effectcraft_engine::time::Tick::ZERO); - assert_eq!(stroke_kind.id(), kind); - } - } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9efa9d940..bcae99bc5 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1808,7 +1808,7 @@ use geometry::{ pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; -pub(crate) use shapes::{layer_at, layers_at, pick, selectable_layers, shape_kind}; +pub(crate) use shapes::{layer_at, layers_at, pick, selectable_layers}; #[cfg(test)] use tools::constrain_45; use tools::{add_motion_key, axis_at, axis_drag, drag_axes, draw_rigs, parent_world_inverse, pen_cursor, pen_cursor_icon, pen_press, select_motion_key}; From bc1734f59ef5ae59e0b44503390823d9153404dd Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 03:56:25 +0100 Subject: [PATCH 52/60] #1284: remove remaining double-click paint helpers --- crates/engine/src/cmd/shape_stroke.rs | 4 +--- crates/ui-egui/src/panels/viewer/shapes.rs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/shape_stroke.rs b/crates/engine/src/cmd/shape_stroke.rs index e81103cdf..ecd3ed8e0 100644 --- a/crates/engine/src/cmd/shape_stroke.rs +++ b/crates/engine/src/cmd/shape_stroke.rs @@ -216,12 +216,10 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4 "fill" if fill.is_none() => fill = at("color").map(rgba), "stroke" if stroke.is_none() => { stroke = at("color").map(rgba); + width = at("width").map(|v| v.as_f64()); } _ => {} } - if width.is_none() && matches!(g.match_id.as_str(), "stroke" | "gstroke") { - width = at("width").map(|v| v.as_f64()); - } }); } (fill, stroke, width) diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs index 1de85e46a..395219a05 100644 --- a/crates/ui-egui/src/panels/viewer/shapes.rs +++ b/crates/ui-egui/src/panels/viewer/shapes.rs @@ -78,7 +78,7 @@ pub(super) struct ShapeDrag { } /// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it. -pub(crate) fn shape_kind(tool: Tool) -> &'static str { +fn shape_kind(tool: Tool) -> &'static str { match tool { Tool::Rectangle => "rect", Tool::RoundedRect => "rounded", From f6728f2527666b911a4c9bfde642e3bfeb56c8b0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 09:58:56 +0700 Subject: [PATCH 53/60] Import: a sequence of bare frame numbers is named after its folder (#2019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files named only by number (0001.png …) gave every sequence the same name, [0001-0250].png, so five imported folders all read alike. Such a sequence now takes its folder's name in front, plate [0001-0250].png; named frames keep After Effects' shot_[0001-0250].png. Co-Authored-By: Claude Opus 5.5 --- crates/project/src/sequence.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/project/src/sequence.rs b/crates/project/src/sequence.rs index fa3d522b5..716c9b4fd 100644 --- a/crates/project/src/sequence.rs +++ b/crates/project/src/sequence.rs @@ -37,14 +37,21 @@ pub fn frame_number(path: &str) -> Option { } /// The After Effects name of a numbered sequence: `shot_[0001-0250].png`. `None` when the first -/// file isn't numbered. +/// file isn't numbered. Files that are only a number (`0001.png`) take their folder's name in +/// front, `plate [0001-0250].png`, so sequences from several folders don't all read the same +/// (#2019). pub fn sequence_name(files: &[String]) -> Option { let (first, last) = (files.first()?, files.last()?); - let name = |p: &str| p.rsplit(['/', '\\']).next().unwrap_or(p).to_string(); + let mut parts = first.rsplit(['/', '\\']); + let first_name = parts.next().unwrap_or(first); + let folder = parts.next().filter(|f| !f.is_empty() && !f.ends_with(':')); let (prefix, a) = split_frame_number(stem_of(first))?; let (_, b) = split_frame_number(stem_of(last))?; - let first_name = name(first); let ext = first_name.rsplit_once('.').map(|(_, e)| format!(".{e}")).unwrap_or_default(); + let prefix = match folder { + Some(f) if prefix.is_empty() => format!("{f} "), + _ => prefix.to_string(), + }; Some(format!("{prefix}[{a}-{b}]{ext}")) } @@ -135,6 +142,13 @@ mod tests { let files: Vec = ["/s/fx_0998.png", "/s/fx_1002.png"].map(String::from).to_vec(); assert_eq!(sequence_name(&files).as_deref(), Some("fx_[0998-1002].png")); assert_eq!(sequence_name(&["/s/a.png".to_string()]), None); + // #2019: frames that are only a number take their folder's name. + let plain: Vec = ["/shots/plate/0001.png", "/shots/plate/0250.png"].map(String::from).to_vec(); + assert_eq!(sequence_name(&plain).as_deref(), Some("plate [0001-0250].png")); + let windows: Vec = ["C:\\shots\\bg\\001.exr", "C:\\shots\\bg\\010.exr"].map(String::from).to_vec(); + assert_eq!(sequence_name(&windows).as_deref(), Some("bg [001-010].exr")); + assert_eq!(sequence_name(&["0001.png".to_string(), "0002.png".to_string()]).as_deref(), Some("[0001-0002].png")); + assert_eq!(sequence_name(&["C:\\0001.png".to_string()]).as_deref(), Some("[0001-0001].png")); } #[test] From b37847429c2c6bc4fcb2f417e5dc9dd6f3a0302f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 09:59:01 +0700 Subject: [PATCH 54/60] Windows: Reveal in Explorer opens File Explorer, not the browser (#1961) Reveal in Explorer sent the folder as a file:/// URL, which egui opens with the web browser on Windows. The frontend now opens file:// folders in File Explorer on Windows (the engine already checks they are plain folders); other platforms and other URLs are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/frame.rs | 47 ++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 7796e02ed..39b228d51 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -98,7 +98,7 @@ impl EffectcraftApp { self.ui.timeline.pps = None; } effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, self.now)), - effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + effectcraft_engine::Event::OpenUrl(url) => open_url(ctx, url), // Frames are keyed by content: an edit keeps those of comps it doesn't touch. effectcraft_engine::Event::ProjectChanged { .. } => {} effectcraft_engine::Event::Frontend { command, params } => { @@ -576,3 +576,48 @@ impl EffectcraftApp { } } } + +/// Opens `url`: on Windows a `file://` folder (Reveal in Explorer) in File Explorer, since the +/// system browser would get it otherwise (#1961); anything else in the browser. +fn open_url(ctx: &egui::Context, url: String) { + #[cfg(windows)] + if let Some(path) = file_url_path(&url) { + // The engine only sends folders it checked are plain folders (`reveal_url`). + if std::process::Command::new("explorer").arg(&path).spawn().is_ok() { + return; + } + } + ctx.open_url(egui::OpenUrl::new_tab(url)); +} + +/// The Windows path of a `file:///C:/…` (or `file:////server/share/…`) URL, percent-decoded. +#[cfg(any(windows, test))] +fn file_url_path(url: &str) -> Option { + let rest = url.strip_prefix("file:///")?; + let mut bytes = Vec::with_capacity(rest.len()); + let mut it = rest.bytes(); + while let Some(b) = it.next() { + if b == b'%' { + let hex = [it.next()?, it.next()?]; + bytes.push(u8::from_str_radix(std::str::from_utf8(&hex).ok()?, 16).ok()?); + } else { + bytes.push(b); + } + } + let path = String::from_utf8(bytes).ok()?.replace('/', "\\"); + // `file:////server/share` is the UNC path `\\server\share`. + Some(if path.starts_with('\\') { format!("\\{path}") } else { path }) +} + +#[cfg(test)] +mod tests { + /// #1961: Reveal's `file://` URLs map back to the folder Explorer opens. + #[test] + fn file_urls_map_to_windows_paths() { + assert_eq!(super::file_url_path("file:///C:/Footage/My%20Shots/").as_deref(), Some("C:\\Footage\\My Shots\\")); + assert_eq!(super::file_url_path("file:///C:/Clips/%E6%98%A0%E5%83%8F").as_deref(), Some("C:\\Clips\\映像")); + assert_eq!(super::file_url_path("file:////server/share/x").as_deref(), Some("\\\\server\\share\\x")); + assert_eq!(super::file_url_path("https://getartcraft.com"), None); + assert_eq!(super::file_url_path("file:///C:/bad%2"), None); + } +} From eef204a79e46947fdcd6ab914fdec4ec6618196e Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 04:20:49 +0100 Subject: [PATCH 55/60] #1284: preserve maximized viewer focus when closing the queue --- crates/ui-egui/src/menus/invoke.rs | 3 +-- crates/ui-egui/src/menus/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 446513f05..87f6a750a 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -220,8 +220,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.apply_dock(craft_ui::docking::Action::Close { panel: PanelKind::RenderQueue })?; // The focus goes to a panel that is still there, so the next Close acts on the composition // instead of on the queue that is gone. - let next = - [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| app.ui.dock.is_visible(*p)).unwrap_or(PanelKind::Project); + let next = [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| panel_shown(app, *p)).unwrap_or(PanelKind::Project); app.show_panel(next); app.focus_panel(ctx, next); // Activating a passive viewer emits OpenComp, which raises Timeline. Finish that routing diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 63fac9879..36d667a72 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -473,6 +473,30 @@ fn closing_the_render_queue_reveals_the_panel_receiving_focus() { } } +/// Closing a floating queue must focus the viewer that is actually shown, retaining maximization. +#[test] +fn closing_a_floating_queue_preserves_the_maximized_composition() { + let mut session = effectcraft_engine::Session::default(); + session.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 2})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(3); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + app.show_panel(PanelKind::Composition); + app.show_panel(PanelKind::RenderQueue); + app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::RenderQueue, rect: [50.0, 60.0, 400.0, 200.0] }).unwrap(); + app.toggle_maximize(PanelKind::Composition); + app.ui.focused = PanelKind::RenderQueue; + assert!(app.ui.dock.is_visible(PanelKind::Timeline), "the hidden underlying tab triggers the regression"); + assert!(!panel_shown(app, PanelKind::Timeline)); + invoke(app, &ctx, "file.close", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().ui.focused, PanelKind::Composition); + assert_eq!(h.state().ui.maximized, Some(PanelKind::Composition)); + assert!(!h.state().ui.dock.is_open(PanelKind::RenderQueue)); + assert_eq!(h.state().session.state.open_comps.len(), 1); +} + #[test] fn queue_close_preserves_state_when_workspace_is_locked() { let ctx = egui::Context::default(); From adff749f33f2a6af83c1b6d565fba552815b2556 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 10:23:11 +0700 Subject: [PATCH 56/60] Tests: Simple Choker keeps growing the matte down to -100 (#2102) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/matte.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/effects/src/matte.rs b/crates/effects/src/matte.rs index 4c42c9c39..d695a3b41 100644 --- a/crates/effects/src/matte.rs +++ b/crates/effects/src/matte.rs @@ -362,6 +362,22 @@ mod tests { assert!(a > 0.99 && c[0] > 0.5, "{c:?}"); } + /// #2102: negative Choke Matte keeps growing the matte past -20, down to -100: a 120 px hole + /// in an opaque frame closes a bit more with every step. + #[test] + fn simple_choker_grows_the_matte_at_large_negative_values() { + let mut img = Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]); + for y in 40..160 { + for x in 40..160 { + img.set(x, y, [0.0; 4]); + } + } + let filled = |c: f64| alpha_sum(&run("ec.matte.simplechoker", &[("chokeMatte", num(c))], img.clone())); + let sums: Vec = [-10.0, -20.0, -40.0, -60.0].into_iter().map(filled).collect(); + assert!(sums.windows(2).all(|w| w[1] > w[0] + 100.0), "{sums:?}"); + assert!((filled(-100.0) - 200.0 * 200.0).abs() < 1.0, "-100 closes the hole"); + } + #[test] fn matte_choker_defaults_shrink() { let img = square(); From 5085a1c873bf95355c0e602103ddf42ab26e4e7b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 10:34:01 +0700 Subject: [PATCH 57/60] Character panel: the stroke width shows its icon and a presets menu (#2075) The stroke width was a field labelled W, which read as the width of the text, not its stroke. It now shows the stroke-width icon (three bars, thin to thick), sits beside the stroke order menu as before, and has a presets menu (0.25 to 100 px) next to its value, as in After Effects; fractional widths show their decimals. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/icons.rs | 7 ++++ crates/ui-egui/src/panels/text_panels.rs | 43 +++++++++++++++++++----- crates/ui-egui/tests/it/ui_text_edit.rs | 17 ++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 385a6b1ca..20a62c68a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -112,6 +112,8 @@ pub enum Icon { Fullscreen, Channel, Exposure, + /// Stroke width: three bars, thin to thick. + StrokeWidth, // links Chat, Globe, @@ -548,6 +550,11 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { } pen.circle(8.0, 8.0, 4.2); } + StrokeWidth => { + pen.rect_fill(2.5, 3.0, 13.5, 3.75); + pen.rect_fill(2.5, 6.5, 13.5, 8.0); + pen.rect_fill(2.5, 10.5, 13.5, 13.0); + } Hamburger => { for y in [4.5, 8.0, 11.5] { pen.line(&[(3.0, y), (13.0, y)]); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 4a2a8b6cd..2e63e203e 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -127,16 +127,21 @@ fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter let row = i / 2; let fx = x0 + col as f32 * (w / 2.0); let fy = y + row as f32 * 28.0; - p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim); - let (r, nv, _) = widgets::hot_number_at( - ui, - pos2(fx + 26.0, fy), - egui::Id::unique(("char", key)), - v, - &widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix }, - &t, - ); + let stroke = key == "strokeWidth"; + if stroke { + // The stroke-width icon, as in After Effects, not a letter that reads as "width". + crate::icons::paint(p, Rect::from_min_size(pos2(fx, fy + 1.0), vec2(18.0, 18.0)), crate::icons::Icon::StrokeWidth, t.text_dim); + } else { + p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim); + } + // Fractional stroke widths (the 0.25 / 0.5 / 0.75 presets) show their decimals. + let decimals = if stroke && v.fract().abs() > 1e-9 { 2 } else { 0 }; + let (r, nv, _) = + widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, &widgets::HotSpec { speed: 0.5, range, decimals, suffix }, &t); app.auto.add(&format!("character.{key}"), r, key); + if stroke { + stroke_width_presets(app, ui, r, enabled, actions); + } if let Some(nv) = nv && enabled { @@ -145,6 +150,26 @@ fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter } } +/// The stroke width's presets menu beside its value (`r`), as in After Effects. +fn stroke_width_presets(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, enabled: bool, actions: &mut Vec) { + const PRESETS: [f64; 22] = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; + let t = app.tokens; + let br = Rect::from_min_size(pos2(r.max.x + 4.0, r.min.y + 2.0), vec2(16.0, 16.0)); + let pop = egui::Id::unique("char-stroke-width-pop"); + if widgets::icon_button(ui, br, crate::icons::Icon::ChevronDown, false, &t, egui::Id::unique("char-stroke-width-presets")) + .on_hover_text(tr("Stroke width presets")) + .clicked() + && enabled + { + widgets::open_popup(ui, pop); + } + app.auto.add("character.strokeWidth.presets", br, "Stroke width presets"); + let labels: Vec = PRESETS.iter().map(|v| format!("{v} px")).collect(); + if let Some(v) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None).and_then(|i| PRESETS.get(i)) { + actions.push(json!({"strokeWidth": v})); + } +} + /// Kerning: Metrics / Optical / a manual value (grid row 1, left). fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) { let t = app.tokens; diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index ea4b56d26..8af1a8e90 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -8,6 +8,7 @@ use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::PanelKind; use egui::{Event, Key, Modifiers, PointerButton, Pos2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; fn app() -> EffectcraftApp { @@ -1750,3 +1751,19 @@ fn character_panel_line_join_buttons_set_the_join() { click(&mut h, bevel, Modifiers::NONE); assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Bevel); } + +/// #2075: the Character panel's stroke width has a presets menu (0.25 … 100 px) beside its value. +#[test] +fn character_panel_stroke_width_presets_set_the_width() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Stroke", "size": 80, "applyStroke": true, "strokeWidth": 8})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let arrow = rect(&h, "character.strokeWidth.presets").center(); + click(&mut h, arrow, Modifiers::NONE); + let entry = h.get_by_label("0.5 px").rect().center(); + click(&mut h, entry, Modifiers::NONE); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().stroke_width, 0.5); +} From cdaba8cce5e1201cd20b6230fd76a37884d33d36 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 10:46:18 +0700 Subject: [PATCH 58/60] rustfmt --- crates/engine/src/tests/text_edit.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 5053ba396..eb1de6f89 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1410,10 +1410,7 @@ fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() { use effectcraft_project::LayerId; let (mut s, _keep) = session("Hello"); let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); - assert!( - s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == LayerId(new)), - "the Type tool opens the edit on its new, empty layer" - ); + assert!(s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == LayerId(new)), "the Type tool opens the edit on its new, empty layer"); let before = layer_count(&s); let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it"); From b04fe30baef76aca8011f6df8a02504fc7964336 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 10:46:48 +0700 Subject: [PATCH 59/60] rustfmt --- crates/ui-egui/src/panels/timeline/tests.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 3263a14ed..ebcbfa8b9 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -565,9 +565,17 @@ fn the_key_menu_is_translated_and_cannot_edit_a_locked_layers_keys() { h.run(); !h.get_by_label(label).accesskit_node().is_disabled() }; - for label in - ["Copy", "Select Equal Keyframes", "Select All Keyframes", "Delete", "Edit Value…", "Easy Ease", "Time-Reverse Keyframes", "Keyframe Velocity…", "Mute Keyframes"] - { + for label in [ + "Copy", + "Select Equal Keyframes", + "Select All Keyframes", + "Delete", + "Edit Value…", + "Easy Ease", + "Time-Reverse Keyframes", + "Keyframe Velocity…", + "Mute Keyframes", + ] { assert!(enabled(false, true, label), "{label} on an unlocked layer"); } for label in ["Copy", "Select Equal Keyframes", "Select All Keyframes"] { From 3a7c4fbbc7053fb3acefdc94acbec7b1c50c2758 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 11:02:17 +0700 Subject: [PATCH 60/60] Timeline key menu: clippy's simpler enabled condition --- crates/ui-egui/src/panels/timeline.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index df3c9a801..7c47dccfb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4177,7 +4177,7 @@ pub(crate) fn key_menu( let edits = !matches!(cmd, "keys.copy" | "keys.selectEqual" | "keys.selectPrevious" | "keys.selectFollowing" | "keys.selectAll"); // An unselected key is selected before the command runs, and locked keys cannot be selected. let unselectable = locked && !selected && cmd != "keys.selectAll"; - if ui.add_enabled(!(locked && edits) && !unselectable, egui::Button::new(crate::i18n::context_item(cmd, ¶ms, lbl))).clicked() { + if ui.add_enabled(!(unselectable || locked && edits), egui::Button::new(crate::i18n::context_item(cmd, ¶ms, lbl))).clicked() { if !selected { actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]}))); }