Merge pull request #3235 from vangel76/effect-controls-lock-3193

Effect Controls: lock the panel to the layer it shows
This commit is contained in:
FredApps
2026-11-14 17:27:24 +01:00
committed by GitHub
10 changed files with 401 additions and 17 deletions
+1
View File
@@ -509,6 +509,7 @@ impl EffectcraftApp {
crate::color_picker::note_dialog(&ctx, self.dialog);
// After the shortcuts, menus and header, which can replace the project (New, Open).
self.ui.timeline.scope_links(self.session.script_ui.project_epoch);
panels::effect_controls::scope_lock(self);
self.dock_area(ui, body);
#[cfg(not(target_arch = "wasm32"))]
prefs_live::dropped_files(self, ui, full);
+15
View File
@@ -344,6 +344,21 @@ fn set_ui(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
{
return err(e);
}
// Effect Controls' tab lock: on locks it to the layer it shows. With no layer shown (the
// lock kept for a layer of another comp, nothing selected here) a lock request is an error
// and the kept lock stays.
if let Some(on) = p.get("effectControlsLocked").and_then(Value::as_bool) {
crate::panels::effect_controls::scope_lock(app);
let shown = crate::panels::effect_controls::shown_layer_id(app).is_some();
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)
}
+2 -2
View File
@@ -676,8 +676,8 @@ pub fn doc_tabs(
out
}
/// A viewer lock button (Composition viewers and the Timeline keep their comp while locked): a
/// closed padlock while locked, an open one while unlocked.
/// A panel lock button (Composition viewers and the Timeline keep their comp while locked,
/// Effect Controls its layer): a closed padlock while locked, an open one while unlocked.
/// Returns true when clicked. Automation: `panel.tab.<panel>.lock`.
pub fn lock_button(ui: &mut egui::Ui, rect: Rect, panel: PanelKind, locked: bool, t: &Tokens, reg: &mut crate::automation::Registry) -> bool {
let resp = ui.interact(rect, egui::Id::unique(("tab-lock", panel.id())), Sense::click());
+24 -6
View File
@@ -333,8 +333,11 @@ impl EffectcraftApp {
}
}
let Some(comp) = self.session.active_comp() else { return out };
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
// Effect Controls names the layer it shows (the one it is locked to, if any).
if let Some(l) = panels::effect_controls::shown_layer_id(self).and_then(|id| comp.layer(id)) {
out.push((PanelKind::EffectControls, format!("{} {}", crate::i18n::command_label("window.panel", "Effect Controls"), l.name)));
}
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
out.push((PanelKind::Properties, format!("{}: {}", crate::i18n::command_label("window.panel", "Properties"), l.name)));
}
out
@@ -492,7 +495,13 @@ impl EffectcraftApp {
}
}
for p in locks {
if !self.ui.locked_tabs.remove(&p.id()) {
if p == PanelKind::EffectControls {
// Locks on the layer the panel shows (nothing to lock without one).
let on = self.ui.effect_controls_lock.is_none();
if panels::effect_controls::set_lock(self, on) != on {
self.ui.status = crate::i18n::tr("Select a layer to lock Effect Controls").to_string();
}
} else if !self.ui.locked_tabs.remove(&p.id()) {
self.ui.locked_tabs.insert(p.id().to_string());
}
}
@@ -638,10 +647,19 @@ fn dock_style(ui: &egui::Ui, t: &Tokens) -> DockStyle {
style
}
/// Panels with a viewer lock: the Composition viewers and the Timeline keep their comp while
/// locked.
/// Panels with a lock: the Composition viewers and the Timeline keep their comp while locked,
/// Effect Controls its layer.
fn has_lock(panel: PanelKind) -> bool {
matches!(panel, PanelKind::Composition | PanelKind::Viewer(_) | PanelKind::Timeline)
matches!(panel, PanelKind::Composition | PanelKind::Viewer(_) | PanelKind::Timeline | PanelKind::EffectControls)
}
/// Whether `panel`'s tab lock is on. Effect Controls keeps the layer it is locked to
/// (`UiState::effect_controls_lock`), the others are in `UiState::locked_tabs`.
fn is_locked(app: &EffectcraftApp, panel: PanelKind) -> bool {
match panel {
PanelKind::EffectControls => app.ui.effect_controls_lock.is_some(),
_ => app.ui.locked_tabs.contains(&panel.id()),
}
}
/// EffectCraft's side of the dock area for one frame.
@@ -865,7 +883,7 @@ impl DockContent<PanelKind> for EffectContent<'_> {
let menu = Rect::from_min_max(pos2((header.max.x - MENU_W).max(header.min.x), header.min.y), header.max);
if has_lock(*active) {
let lock = Rect::from_center_size(pos2(menu.min.x - LOCK_W / 2.0, header.center().y), vec2(14.0, 14.0));
let locked = self.app.ui.locked_tabs.contains(&active.id());
let locked = is_locked(self.app, *active);
if dock::lock_button(ui, lock, *active, locked, &t, &mut self.app.auto) {
self.locks.push(*active);
}
+9 -2
View File
@@ -1,6 +1,13 @@
//! Effect Controls: the selected layer's effects with all parameters, in After Effects'
//! conventions:
//!
//! - the tab's lock icon keeps the panel on the layer it shows whatever is selected next (a
//! pick whip from another layer's property can then reach its parameters); deleting that
//! layer or replacing the project unlocks it (see [`set_lock`]). The lock holds while the
//! locked layer's comp is the active comp: with another comp active the panel shows that
//! comp's selection (its edits address layers of the active comp), and the locked layer
//! again when its comp is back. The Effect menu of a right-click on empty space applies to
//! the selected layers, as in After Effects;
//! - effect headers with the fx switch, twirl, Reset and About…; click selects the effect
//! (Edit ▸ Copy / Paste / Duplicate and Delete then act on it), drag reorders the stack, the
//! context menu copies, pastes, duplicates, resets and removes;
@@ -34,8 +41,8 @@ mod panel;
mod rows;
mod viewer;
pub(crate) use common::gesture_key;
pub use common::{choose_files_after_apply, reveal_applied};
pub use common::{choose_files_after_apply, reveal_applied, set_lock};
pub(crate) use common::{gesture_key, scope_lock, shown_layer_id};
pub(crate) use panel::handle_disclosure_key;
pub use panel::{show, start_rename};
pub(crate) use rows::toggle_pick;
@@ -27,10 +27,70 @@ pub(super) fn effect_active(g: &PropGroup, layer: effectcraft_engine::project::L
selected.iter().any(|(l, uid)| *l == layer && contains(g, *uid, 0))
}
pub(super) fn selected_layer(app: &EffectcraftApp) -> Option<Layer> {
/// The layer Effect Controls shows: the layer it is locked to (its tab's lock) while that layer's
/// comp is the active comp, else the first selected layer of the active comp.
pub(crate) fn shown_layer_id(app: &EffectcraftApp) -> Option<effectcraft_engine::project::LayerId> {
let comp = app.session.active_comp()?;
let id = app.session.state.selected_layers.first()?;
comp.layer(*id).cloned()
if let Some(lock) = app.ui.effect_controls_lock
&& app.session.active_comp_id().is_some_and(|c| c.0 == lock.comp)
&& comp.layer(effectcraft_engine::project::LayerId(lock.layer)).is_some()
{
return Some(effectcraft_engine::project::LayerId(lock.layer));
}
app.session.state.selected_layers.first().copied().filter(|id| comp.layer(*id).is_some())
}
/// Returns the layer Effect Controls currently shows, locked or selected, or `None` when there is none.
pub(super) fn shown_layer(app: &EffectcraftApp) -> Option<Layer> {
let id = shown_layer_id(app)?;
app.session.active_comp()?.layer(id).cloned()
}
/// Lock Effect Controls on the layer it shows (`on`), or unlock it. Locking with no layer shown
/// does nothing; locking while already locked keeps the layer it is locked to. Returns whether
/// the panel is locked afterwards. The tab's lock icon and `ui.set {effectControlsLocked}`.
pub fn set_lock(app: &mut EffectcraftApp, on: bool) -> bool {
// A lock left from a replaced project is gone before this one is answered (a control
// request can run between the replacement and the next frame).
scope_lock(app);
if !on {
app.ui.effect_controls_lock = None;
return false;
}
if app.ui.effect_controls_lock.is_some() {
return true;
}
let Some(comp) = app.session.active_comp_id() else { return false };
let Some(layer) = shown_layer_id(app) else { return false };
app.ui.effect_controls_lock = Some(crate::state::EffectControlsLock { comp: comp.0, layer: layer.0 });
app.ui.effect_controls_lock_epoch = app.session.script_ui.project_epoch;
true
}
/// Once a frame: a lock made in another project (New, Open: the ids start over) or on a layer or
/// comp since deleted is dropped, whether or not Effect Controls is on screen.
pub(crate) fn scope_lock(app: &mut EffectcraftApp) {
let epoch = app.session.script_ui.project_epoch;
if app.ui.effect_controls_lock_epoch != epoch {
app.ui.effect_controls_lock_epoch = epoch;
app.ui.effect_controls_lock = None;
}
prune_lock(app);
}
/// A lock whose layer or comp no longer exists (deleted, or its comp deleted) is dropped, so the
/// panel follows the selection again and its tab shows it unlocked. Another comp being active
/// keeps the lock: the panel shows the locked layer again when its comp comes back.
pub(super) fn prune_lock(app: &mut EffectcraftApp) {
let Some(lock) = app.ui.effect_controls_lock else { return };
let alive = app
.session
.project
.comp(effectcraft_engine::project::ItemId(lock.comp))
.is_some_and(|c| c.layer(effectcraft_engine::project::LayerId(lock.layer)).is_some());
if !alive {
app.ui.effect_controls_lock = None;
}
}
/// An effect was just applied (`effect.apply` / `effect.applyLast` with `params`): Effect
@@ -39,10 +99,14 @@ pub(super) fn selected_layer(app: &EffectcraftApp) -> Option<Layer> {
pub fn reveal_applied(app: &mut EffectcraftApp, params: &serde_json::Value) {
// `effect.apply` selects the new effect on (the last of) the layers it went to.
let target = app.session.state.selected_props.last().map(|(l, _)| *l);
// Effect Controls locked to a layer of the active comp keeps showing that layer.
let locked = app.ui.effect_controls_lock.filter(|k| app.session.active_comp_id().is_some_and(|c| c.0 == k.comp));
if let Some(l) = target
&& !app.session.state.selected_layers.contains(&l)
&& params.get("comp").is_none()
&& app.session.active_comp().is_some_and(|c| c.layer(l).is_some())
// Dropped on the locked panel: it shows that layer already, the selection stays.
&& locked.is_none_or(|k| k.layer != l.0)
{
// Show that layer (dropped on an unselected one), keeping the new effect selected.
let props = app.session.state.selected_props.clone();
@@ -51,6 +115,14 @@ pub fn reveal_applied(app: &mut EffectcraftApp, params: &serde_json::Value) {
Err(e) => app.ui.status = e.to_string(),
}
}
// The effect went to another layer than the one the locked panel shows: say why it isn't
// there (After Effects opens another Effect Controls tab).
if let (Some(k), Some(l)) = (locked, target)
&& k.layer != l.0
&& let Some(name) = app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(k.layer))).map(|x| x.name.clone())
{
app.ui.status = crate::i18n::tr_args("Effect Controls is locked to {}", &[&name]);
}
app.raise_panel(crate::dock::PanelKind::EffectControls);
}
@@ -1,4 +1,4 @@
use super::common::{Actions, PARAM_NAME_X, ROW, effect_active, selected_layer};
use super::common::{Actions, PARAM_NAME_X, ROW, effect_active, prune_lock, shown_layer};
use super::editors::{curves_editor, editor_height, histogram_only, levels_editor, object_matte_editor, roto_editor, warp_editor};
use super::rows::{FxScope, group_rows, toggle_closed};
use super::{Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Icon, Layer, PropGroup, Rect, Sense, Stroke, Tokens, json, pos2, vec2, widgets};
@@ -199,7 +199,8 @@ fn about_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, bp: &egui::Painter,
}
}
/// The Effect Controls panel for the selected layer: a header with the comp and layer names, then
/// The Effect Controls panel for the selected layer (or the layer the panel is locked to, see
/// `common::shown_layer_id`): a header with the comp and layer names, then
/// one header per effect (Fx toggle, twirl, rename, Reset, About, label bar, reorder drag and its
/// right-click menu) followed by that effect's custom editor and its property rows. Dragging
/// effects or presets onto the panel applies them; a right-click on empty space opens the Effect
@@ -209,7 +210,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let ctx = ui.ctx().clone();
let Some(layer) = selected_layer(app) else {
// A locked layer that was deleted unlocks the panel, which then follows the selection.
prune_lock(app);
let Some(layer) = shown_layer(app) else {
p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Select a layer to see its effects"), Tokens::ui(12.0), t.text_faint);
return;
};
+21
View File
@@ -594,6 +594,14 @@ impl Default for AnimToolsState {
}
}
/// The layer a locked Effect Controls panel keeps showing (the tab's lock icon, as After Effects'
/// panel lock): comp item id and layer id.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EffectControlsLock {
pub comp: u64,
pub layer: u64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ProjectStatus {
@@ -651,6 +659,17 @@ pub struct UiState {
/// bring a locked panel forward (the tab's lock icon, as After Effects' viewer lock).
#[serde(default)]
pub locked_tabs: BTreeSet<String>,
/// Effect Controls locked to a layer (its tab's lock icon): the panel keeps showing that
/// layer whatever is selected while its comp is the active comp. Cleared when the layer or
/// its comp is deleted, and when the project is replaced (see
/// `panels::effect_controls::set_lock`).
#[serde(default)]
pub effect_controls_lock: Option<EffectControlsLock>,
/// The project the lock was made in (`ScriptUi::project_epoch`): a new or opened project
/// reuses comp and layer ids, so its lock is dropped (see
/// `panels::effect_controls::scope_lock`).
#[serde(skip)]
pub effect_controls_lock_epoch: u64,
/// The comp each Composition viewer shows, by viewer id (0 = the Composition panel; see
/// `panels::viewers`).
#[serde(default)]
@@ -773,6 +792,8 @@ impl Default for UiState {
native_outer: Vec::new(),
maximized: None,
locked_tabs: BTreeSet::new(),
effect_controls_lock: None,
effect_controls_lock_epoch: 0,
viewers: Default::default(),
active_viewer: 0,
focused: PanelKind::Composition,
@@ -1139,3 +1139,250 @@ fn double_click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) {
}
h.run_steps(2);
}
/// Two solids in one comp, `A` with a Point Control and `B` with an Angle Control, `A` selected
/// and Effect Controls open: the app, A, B and their effects.
fn two_layers() -> (EffectcraftApp, u64, u64, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 5})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "A", "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"name": "B", "color": "#0000ff"})).unwrap()["layer"].as_u64().unwrap();
let fa = s.execute("effect.apply", json!({"layers": [a], "effect": "Point Control"})).unwrap()["effects"][0].as_u64().unwrap();
let fb = s.execute("effect.apply", json!({"layers": [b], "effect": "Angle Control"})).unwrap()["effects"][0].as_u64().unwrap();
s.state.selected_layers = vec![LayerId(a)];
s.state.selected_props.clear();
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
(app, a, b, fa, fb)
}
/// Whether Effect Controls drew effect `fx`'s header in the last frame (not the frame before:
/// a stale id must not pass).
fn shows_effect(h: &Harness<'_, EffectcraftApp>, fx: u64) -> bool {
let id = format!("effectControls.effect.{fx}");
h.state().auto.elements.iter().any(|e| e.id == id)
}
/// Selects `layer` through the `layer.select` command and runs a few frames.
fn select_layer(h: &mut Harness<'_, EffectcraftApp>, layer: u64) {
h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap();
h.run_steps(3);
}
/// #3193: the Effect Controls tab's lock keeps the panel (and its tab name) on the layer it showed
/// when locked whatever is selected next, through undo; deleting that layer unlocks it without a
/// crash and the panel follows the selection again.
#[test]
fn locked_effect_controls_keep_their_layer_until_it_is_deleted() {
let (app, a, b, fa, fb) = two_layers();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(4);
assert!(shows_effect(&h, fa) && !shows_effect(&h, fb), "A is shown before locking");
let comp = h.state().session.active_comp_id().unwrap().0;
let lock = rect_of(&h, "panel.tab.EffectControls.lock").center();
click(&mut h, lock);
assert_eq!(h.state().ui.effect_controls_lock, Some(effectcraft_ui_egui::state::EffectControlsLock { comp, layer: a }));
// Selecting B keeps A in the panel and its tab.
select_layer(&mut h, b);
assert!(shows_effect(&h, fa) && !shows_effect(&h, fb), "the locked panel keeps A");
let tab = h.state().auto.elements.iter().find(|e| e.id == "panel.tab.EffectControls").map(|e| e.label.clone()).unwrap_or_default();
assert!(tab.ends_with(" A"), "the tab names the locked layer: {tab:?}");
// An edit and its undo leave the lock alone.
h.state_mut().session.execute("prop.set", json!({"layer": b, "path": "transform/opacity", "value": 50.0})).unwrap();
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(3);
assert!(shows_effect(&h, fa) && !shows_effect(&h, fb), "undo keeps the lock");
// The lock state is UI state: it survives a serde round trip.
let ui = serde_json::to_value(&h.state().ui).unwrap();
let back: effectcraft_ui_egui::state::UiState = serde_json::from_value(ui).unwrap();
assert_eq!(back.effect_controls_lock, h.state().ui.effect_controls_lock);
// Unlocking follows the selection again.
let lock = rect_of(&h, "panel.tab.EffectControls.lock").center();
click(&mut h, lock);
assert_eq!(h.state().ui.effect_controls_lock, None);
assert!(shows_effect(&h, fb) && !shows_effect(&h, fa), "unlocked, the panel shows the selection");
// Lock on A again, select B, delete A: no crash, the lock is gone, B is shown.
select_layer(&mut h, a);
let lock = rect_of(&h, "panel.tab.EffectControls.lock").center();
click(&mut h, lock);
assert_eq!(h.state().ui.effect_controls_lock.map(|l| l.layer), Some(a));
select_layer(&mut h, b);
h.state_mut().session.execute("edit.clear", json!({"layers": [a]})).unwrap();
h.run_steps(3);
assert_eq!(h.state().ui.effect_controls_lock, None, "deleting the locked layer unlocks the panel");
assert!(!shows_effect(&h, fa), "A is gone from the panel");
// Deleting clears the selection; the panel follows the next one.
select_layer(&mut h, b);
assert!(shows_effect(&h, fb), "the panel follows the selection");
// Undoing the delete brings A back without bringing back (or breaking) the lock.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(3);
assert_eq!(h.state().ui.effect_controls_lock, None);
let selected = h.state().session.state.selected_layers.first().map(|l| l.0);
let (on, off) = if selected == Some(a) { (fa, fb) } else { (fb, fa) };
assert!(shows_effect(&h, on) && !shows_effect(&h, off), "the panel shows the selection ({selected:?})");
}
/// #3193: a new or opened project reuses comp and layer ids, so a lock does not carry into it,
/// even onto a layer with the locked one's ids.
#[test]
fn replacing_the_project_unlocks_effect_controls() {
let (app, a, b, _, _) = two_layers();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(4);
let lock = rect_of(&h, "panel.tab.EffectControls.lock").center();
click(&mut h, lock);
assert_eq!(h.state().ui.effect_controls_lock.map(|l| l.layer), Some(a));
select_layer(&mut h, b);
// The same project opened again: every id is alive.
let project = (*h.state().session.project).clone();
h.state_mut().session.replace_project(project, None);
h.run_steps(3);
assert_eq!(h.state().ui.effect_controls_lock, None, "the lock belongs to the replaced project");
}
/// #3193: with the lock kept for a layer of another comp and no layer selected in the active one,
/// Effect Controls shows no layer: a lock request is an error and the kept lock stays.
#[test]
fn a_lock_request_with_no_layer_shown_fails_and_keeps_the_other_comps_lock() {
use effectcraft_ui_egui::control::{self, ControlRequest, Outcome};
/// Sends a `ui.set` request that locks Effect Controls and returns the immediate reply.
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 becomes active, nothing selected in it.
h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "frameRate": 30, "duration": 5})).unwrap();
h.state_mut().session.state.selected_layers.clear();
h.run_steps(3);
assert_ne!(h.state().session.active_comp_id().map(|c| c.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: 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};
/// Sends a `ui.set` request that locks Effect Controls and returns the immediate reply.
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]
fn a_lock_request_after_a_project_replacement_does_not_report_the_old_lock() {
use effectcraft_ui_egui::control::{self, ControlRequest, Outcome};
/// Sends a `ui.set` request that locks Effect Controls and returns the immediate reply.
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);
assert_eq!(h.state().ui.effect_controls_lock.map(|l| l.layer), Some(a));
let project = (*h.state().session.project).clone();
h.state_mut().session.replace_project(project, None);
let reply = send(&mut h);
h.run_steps(3);
assert_eq!(reply["ok"] == true, h.state().ui.effect_controls_lock.is_some(), "{reply}");
}
/// #3193: an effect dropped on the locked panel goes to the locked layer and leaves the selection
/// alone; one applied to another layer says why the panel doesn't show it.
#[test]
fn applying_an_effect_while_locked_keeps_the_selection() {
let (app, a, b, fa, _) = two_layers();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(4);
let lock = rect_of(&h, "panel.tab.EffectControls.lock").center();
click(&mut h, lock);
select_layer(&mut h, b);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.apply", json!({"effect": "Color Control", "layers": [a]})).unwrap();
h.run_steps(3);
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(b)], "the selection stays on B");
assert!(shows_effect(&h, fa), "the panel still shows A");
assert_eq!(h.state().ui.effect_controls_lock.map(|l| l.layer), Some(a));
// Applied to the selected B: the panel stays on A and says so.
h.state_mut().ui.status.clear();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.apply", json!({"effect": "Color Control", "layers": [b]})).unwrap();
h.run_steps(3);
assert!(shows_effect(&h, fa));
assert_eq!(h.state().ui.status, "Effect Controls is locked to A");
}
/// #3193: agents lock Effect Controls with `ui.set {effectControlsLocked}`; with no layer shown
/// there is nothing to lock.
#[test]
fn effect_controls_lock_over_the_control_channel() {
use effectcraft_ui_egui::control::{self, ControlRequest, Outcome};
/// Sends a `ui.set` request with `params` and returns the immediate reply.
fn send(h: &mut Harness<'_, EffectcraftApp>, params: serde_json::Value) -> serde_json::Value {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new("ui.set", params);
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, b, fa, fb) = 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, json!({"effectControlsLocked": true}))["ok"], true);
assert_eq!(h.state().ui.effect_controls_lock.map(|l| l.layer), Some(a));
select_layer(&mut h, b);
assert!(shows_effect(&h, fa) && !shows_effect(&h, fb));
assert_eq!(send(&mut h, json!({"effectControlsLocked": false}))["ok"], true);
h.run_steps(3);
assert!(shows_effect(&h, fb) && !shows_effect(&h, fa));
// Nothing selected: nothing to lock.
h.state_mut().session.state.selected_layers.clear();
h.run_steps(2);
assert_eq!(send(&mut h, json!({"effectControlsLocked": true}))["ok"], false);
assert_eq!(h.state().ui.effect_controls_lock, None);
}
+1 -1
View File
@@ -86,7 +86,7 @@ The control transport's 60-second request timeout still applies to long renders.
|---|---|---|
| `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?, timeline?, menuBar?, home?}` | `viewer: {zoom (null = fit), res: Full\|Half\|Third\|Quarter\|Auto, pan: [x,y], grid, rulers, safeMargins, transparencyGrid}`; `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). |