From d1bdb701b74b5e7b6b764d66607b31af0f1ba109 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 18:22:03 +0100 Subject: [PATCH] Effect Controls: a lock request while locked in another comp is an error (#3193) Locked to a layer of another comp, the panel shows the active comp's selection and its tab lock reads locked (a click unlocks). ui.set {effectControlsLocked: true} reported success there without locking the shown layer; it now returns an error and keeps the other comp's lock. --- crates/ui-egui/src/control.rs | 5 +++ crates/ui-egui/tests/it/ui_effect_controls.rs | 31 +++++++++++++++++++ docs/control-protocol.md | 2 +- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b9ff46c4b..15a10380d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -290,6 +290,11 @@ fn set_ui(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { if (on && !shown) || crate::panels::effect_controls::set_lock(app, on) != on { return err("effectControlsLocked: Effect Controls shows no layer to lock"); } + // Locked to a layer of another comp, the panel shows this comp's selection: the tab's + // lock reads locked and a click unlocks, so a lock request does not lock what is shown. + if on && app.ui.effect_controls_lock.is_some_and(|k| app.session.active_comp_id().is_none_or(|c| c.0 != k.comp)) { + return err("effectControlsLocked: Effect Controls is locked to a layer of another composition; unlock it first"); + } } set_ui_layout(app, p) } diff --git a/crates/ui-egui/tests/it/ui_effect_controls.rs b/crates/ui-egui/tests/it/ui_effect_controls.rs index ffafe867d..ec7d5683d 100644 --- a/crates/ui-egui/tests/it/ui_effect_controls.rs +++ b/crates/ui-egui/tests/it/ui_effect_controls.rs @@ -1043,6 +1043,37 @@ fn a_lock_request_with_no_layer_shown_fails_and_keeps_the_other_comps_lock() { assert_eq!(h.state().ui.effect_controls_lock, kept, "the other comp's lock stays"); } +/// #3193: locked to a layer of another comp, the panel shows this comp's selection; a lock request +/// does not claim to lock that layer: it is an error and the other comp's lock stays. +#[test] +fn a_lock_request_while_locked_in_another_comp_fails() { + use effectcraft_ui_egui::control::{self, ControlRequest, Outcome}; + fn send(h: &mut Harness<'_, EffectcraftApp>) -> serde_json::Value { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new("ui.set", json!({"effectControlsLocked": true})); + let now = h.state().now(); + match control::handle(h.state_mut(), &ctx, &req, now) { + Outcome::Done(v) => v, + _ => panic!("ui.set: no immediate reply"), + } + } + let (app, a, _, _, _) = two_layers(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + assert_eq!(send(&mut h)["ok"], true); + let kept = h.state().ui.effect_controls_lock; + assert_eq!(kept.map(|l| l.layer), Some(a)); + // Another comp with a selected layer becomes active: the panel shows that layer. + h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "frameRate": 30, "duration": 5})).unwrap(); + let c = h.state_mut().session.execute("layer.newSolid", json!({"name": "C", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.state.selected_layers = vec![LayerId(c)]; + h.run_steps(3); + assert_ne!(h.state().session.active_comp_id().map(|x| x.0), kept.map(|l| l.comp), "another comp is active"); + let reply = send(&mut h); + assert_eq!(reply["ok"], false, "{reply}"); + assert_eq!(h.state().ui.effect_controls_lock, kept, "the other comp's lock stays"); +} + /// #3193: a lock request right after the project is replaced, before the next frame, is not /// answered with the old project's lock: the reply matches the lock the panel keeps. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8af641a3a..126b8fd02 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -66,7 +66,7 @@ panel registers an element id; both are enforced by `crates/ui-egui/tests/it/ui_ |---|---|---| | `ui.inspect` | `{}` | Window size, pixels per point, fps, serialized `UiState`, playback, time, active comp, selection, open dialog, last render ms, `keyboardFocus`, `windowFocused`, `workspaceStorageError`. | | `ui.elements` | `{prefix?}` | `[{id, label, rect: [x, y, w, h]}]`: the widgets drawn in the last frame, in points. | -| `ui.set` | `{tool?, workspace?, theme?, focused?, viewer?, effectControlsLocked?, timeline?, menuBar?, home?}` | `viewer: {zoom (null = fit), res: Full\|Half\|Third\|Quarter\|Auto, pan: [x,y], grid, rulers, safeMargins, transparencyGrid}`; `effectControlsLocked: true` locks Effect Controls on the layer it shows (an error when it shows none), `false` unlocks it (the tab's lock icon, `panel.tab.EffectControls.lock`; `ui.inspect` reports it as `effect_controls_lock: {comp, layer}`); `timeline: {pps (null = fit), start, graphEditor, showModes, openLayers: [ids], openGroups: [uids]}`. | +| `ui.set` | `{tool?, workspace?, theme?, focused?, viewer?, effectControlsLocked?, timeline?, menuBar?, home?}` | `viewer: {zoom (null = fit), res: Full\|Half\|Third\|Quarter\|Auto, pan: [x,y], grid, rulers, safeMargins, transparencyGrid}`; `effectControlsLocked: true` locks Effect Controls on the layer it shows (an error when it shows none, or while it is locked to a layer of another composition), `false` unlocks it (the tab's lock icon, `panel.tab.EffectControls.lock`; `ui.inspect` reports it as `effect_controls_lock: {comp, layer}`); `timeline: {pps (null = fit), start, graphEditor, showModes, openLayers: [ids], openGroups: [uids]}`. | | `ui.panel.show` / `ui.panel.close` | `{panel}` | Panels: `Project`, `Composition`, `Timeline`, `EffectControls`, `EffectsPresets`, `Info`, `Preview`, `Character`, `Paragraph`, `Align` and others. | | `ui.playback` | `{action: play\|stop\|toggle\|status}` | `{playing, time, audio, levelsDb, peaksDb}` (audio: an audio preview is running; L/R meter levels and held peaks in dBFS) | | `ui.resize` | `{width, height}` | Resizes the window (points). |