Layer Styles: Blending Options can be edited on a layer with no styles (#1389)

This commit is contained in:
Marco Arnulfo
2026-10-30 00:44:57 +01:00
parent a8d83b9717
commit cc94fccda5
6 changed files with 113 additions and 14 deletions
+15 -2
View File
@@ -25,13 +25,23 @@ fn add_styles(s: &mut Session, p: &Value, cmd: &str, styles: &[&str], enabled: b
if ids.is_empty() {
return Err(bad(cmd, "no layer that takes layer styles (footage, solid, precomp, text or shape layer)"));
}
let uids = s.edit(label, None, |proj, state| {
let uids = s.edit(label, super::merge_p(p), |proj, state| {
let gl = proj.comp(cid).ok_or(EngineError::NoComp)?.global_light.clone();
let mut out = vec![];
let mut sel = vec![];
for lid in &ids {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, *lid)?;
// No style: Blending Options alone (the Layer Style dialog's first change on a layer
// without styles).
if styles.is_empty() {
let uid = st::add_blending_options(l, &mut Ids(&mut next), &gl).ok_or_else(|| bad(cmd, "no Blending Options"))?;
if let Some(g) = l.props.sub_mut(GROUP) {
g.enabled = true;
}
out.push(json!({"layer": lid.0, "style": st::BLENDING, "uid": uid}));
sel.push((*lid, uid));
}
for id in styles {
let had = l.layer_styles().and_then(|g| g.sub(id)).is_some();
let uid = st::add_style(l, &mut Ids(&mut next), id, &gl, enabled).ok_or_else(|| bad(cmd, format!("unknown style `{id}`")))?;
@@ -58,6 +68,9 @@ fn add_styles(s: &mut Session, p: &Value, cmd: &str, styles: &[&str], enabled: b
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "style").ok_or_else(|| bad("layer.style.add", "missing `style` (e.g. dropShadow or \"Drop Shadow\")"))?;
if name.eq_ignore_ascii_case(st::BLENDING) || name.eq_ignore_ascii_case("Blending Options") {
return add_styles(s, p, "layer.style.add", &[], true, "Blending Options");
}
let id = st::style_id(name)
.ok_or_else(|| bad("layer.style.add", format!("unknown style `{name}`; one of {}", STYLES.iter().map(|s| s.0).collect::<Vec<_>>().join(", "))))?;
let label = STYLES.iter().find(|s| s.0 == id).map(|s| s.1).unwrap_or(id);
@@ -224,7 +237,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Layer Style",
[],
None,
"{style: dropShadow|innerShadow|outerGlow|innerGlow|bevelEmboss|satin|colorOverlay|gradientOverlay|stroke (or display name), layers?}",
"{style: dropShadow|innerShadow|outerGlow|innerGlow|bevelEmboss|satin|colorOverlay|gradientOverlay|stroke (or display name), or blendingOptions (the Layer Styles group with Blending Options only), layers?, merge?}",
has_layers,
add
),
+27
View File
@@ -117,3 +117,30 @@ fn styles_render_and_round_trip() {
assert_eq!(back, *s.project);
assert_eq!(back.comp(cid).unwrap().global_light.angle.value.as_f64(), 33.0);
}
/// #1389: Blending Options on a layer with no styles. `layer.style.add` with `blendingOptions`
/// creates the Layer Styles group with Blending Options only; with the same merge key as the
/// value set right after it, both are one undo step, and undoing it leaves no group behind.
#[test]
fn blending_options_alone_create_the_group_in_one_undo_step() {
let (mut s, a, b) = session();
let undo = s.history.undo.len();
let r = s.execute("layer.style.add", json!({"layers": [a], "style": "blendingOptions", "merge": "bo"})).unwrap();
assert_eq!(r["styles"][0]["style"], "blendingOptions", "{r}");
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().clone();
let g = layer.layer_styles().expect("the group is created");
assert!(g.sub("blendingOptions").is_some());
assert!(styles_of(&s, a).is_empty(), "no style is added with it");
s.execute("prop.set", json!({"layer": a, "path": "layerStyles/blendingOptions/advancedBlending/fillOpacity", "value": 40, "merge": "bo"})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "layerStyles/blendingOptions/advancedBlending/fillOpacity", "value": 30, "merge": "bo"})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1, "creating the group and setting the value are one step");
// The other layer is untouched, and Global Light is the comp's.
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(b)).unwrap().layer_styles().is_none());
let gl = s.execute("prop.get", json!({"layer": a, "path": "layerStyles/blendingOptions/globalLightAngle"})).unwrap();
assert!(gl.to_string().contains("120"), "{gl}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().layer_styles().is_none(), "one undo removes the group");
// By display name too.
s.execute("layer.style.add", json!({"layers": [a], "style": "Blending Options"})).unwrap();
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().layer_styles().is_some());
}
+12 -3
View File
@@ -314,14 +314,23 @@ impl Layer {
}
}
/// Give `layer` its Layer Styles group (Blending Options only, after Transform) when it has none.
/// Returns the Blending Options group's uid.
pub fn add_blending_options(layer: &mut Layer, ids: &mut Ids, gl: &GlobalLight) -> Option<u64> {
if layer.props.sub(GROUP).is_none() {
let g = layer_styles(ids, gl);
let at = layer.props.children.iter().position(|c| c.match_id() == "transform").map(|i| i + 1).unwrap_or(layer.props.children.len());
layer.props.children.insert(at, g.into());
}
Some(layer.props.sub(GROUP)?.sub(BLENDING)?.uid)
}
/// Add style `id` to `layer` (creating the Layer Styles group after Transform when missing).
/// Returns the style group's uid; an existing style of that kind is kept and returned.
pub fn add_style(layer: &mut Layer, ids: &mut Ids, id: &str, gl: &GlobalLight, enabled: bool) -> Option<u64> {
let order = STYLES.iter().position(|s| s.0 == id)?;
if layer.props.sub(GROUP).is_none() {
let g = layer_styles(ids, gl);
let at = layer.props.children.iter().position(|c| c.match_id() == "transform").map(|i| i + 1).unwrap_or(layer.props.children.len());
layer.props.children.insert(at, g.into());
add_blending_options(layer, ids, gl);
}
let ls = layer.props.sub_mut(GROUP)?;
if let Some(g) = ls.sub(id) {
@@ -7,7 +7,8 @@
use crate::i18n::tr;
use effectcraft_engine::commands::text_edit::active_editor_snapshot;
use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::styles::{BLENDING, STYLES};
use effectcraft_engine::project::build::Ids;
use effectcraft_engine::project::styles::{BLENDING, GROUP, STYLES, blending_options};
use effectcraft_engine::project::{Layer, LayerId, ParamUi, Property};
use egui::vec2;
use serde_json::{Value, json};
@@ -134,6 +135,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
};
let lid = l.id.0;
let styles = l.layer_styles().cloned();
// Shown only: its uids are never stored in the project.
let mut next = app.session.project.next_id;
let blending_template =
if styles.is_none() && l.can_have_styles() { app.session.active_comp().map(|c| blending_options(&mut Ids(&mut next), &c.global_light)) } else { None };
let sel = app.dialog_state.layer_style.selected.clone();
let time = app.session.time();
let lt = l.layer_time(time);
@@ -198,7 +203,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let head = ui.label(egui::RichText::new(name).font(Tokens::semibold(13.0)));
app.auto.add("dialog.layerStyle.page", head.rect, &sel);
ui.add_space(6.0);
let Some(g) = styles.as_ref().and_then(|s| s.sub(&sel)) else {
// A layer with no styles still shows Blending Options, with the values the Layer
// Styles group would start from; the first change creates the group (#1389).
let missing = blending_template.as_ref().filter(|_| sel == BLENDING && styles.is_none());
let Some(g) = styles.as_ref().and_then(|s| s.sub(&sel)).or(missing) else {
ui.label(egui::RichText::new(tr("Not applied: tick it in the list to add it.")).weak());
return;
};
@@ -220,13 +228,21 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let lbl = ui.label(&p.name);
let v = p.value_at(lt);
let edit = ui.scope(|ui| control(ui, p, &v));
if let Some(nv) = edit.inner {
actions.push((
"prop.set".into(),
json!({"layer": lid, "prop": p.uid, "value": nv, "merge": format!("layer-style-dialog-{}", p.uid)}),
));
}
let path = g.match_path_of(p.uid).unwrap_or_default();
if let Some(nv) = edit.inner {
// Keyed by path, not uid: the group created by the first
// change and the rest of the drag stay one undo step.
let merge = format!("layer-style-dialog-{lid}-{sel}/{path}");
if missing.is_some() {
actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": BLENDING, "merge": merge})));
actions.push((
"prop.set".into(),
json!({"layer": lid, "path": format!("{GROUP}/{BLENDING}/{path}"), "value": nv, "merge": merge}),
));
} else {
actions.push(("prop.set".into(), json!({"layer": lid, "prop": p.uid, "value": nv, "merge": merge})));
}
}
app.auto.add(&format!("dialog.layerStyle.prop.{sel}/{path}"), lbl.rect, &p.name);
app.auto.add(&format!("dialog.layerStyle.value.{sel}/{path}"), edit.response.rect, &p.name);
ui.end_row();
+32
View File
@@ -208,6 +208,38 @@ fn layer_style_controls_stay_reachable_in_a_small_window() {
}
}
/// #1389: on a layer with no styles, Blending Options shows its controls instead of an empty
/// page. Opening the dialog adds nothing; the first change creates the Layer Styles group, and
/// the group and the whole drag are one undo step.
#[test]
fn layer_style_blending_options_edit_a_layer_without_styles() {
let mut h = harness(1400.0);
let lid = h.state().session.active_comp().unwrap().layers[0].id.0;
h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.options", json!({})).unwrap();
h.run_steps(5);
let has_group = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers[0].layer_styles().is_some();
let undo = h.state().session.history.undo.len();
assert_eq!(h.state().auto.find("dialog.layerStyle.page").unwrap().label, "blendingOptions");
let fill = "dialog.layerStyle.value.blendingOptions/advancedBlending/fillOpacity";
assert!(h.state().auto.find("dialog.layerStyle.value.blendingOptions/globalLightAngle").is_some(), "Global Light is shown");
let at = in_view(&h, fill);
assert!(!has_group(&h), "opening the page adds nothing");
drag(&mut h, at, at - vec2(40.0, 0.0));
let v =
h.state_mut().session.execute("prop.get", json!({"layer": lid, "path": "layerStyles/blendingOptions/advancedBlending/fillOpacity"})).unwrap()["value"]
.as_f64()
.unwrap();
assert!(v < 100.0, "Fill Opacity was dragged: {v}");
assert!(styles(&mut h).is_empty(), "no style was added");
assert_eq!(h.state().session.history.undo.len(), undo + 1, "the group and the drag are one undo step");
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles));
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(!has_group(&h), "one undo removes the group");
}
#[test]
fn layer_style_dialog_moves_by_its_title() {
let mut h = harness(1400.0);
+3 -1
View File
@@ -243,7 +243,9 @@ the layer body. Interior styles are clipped to the layer: they blend onto its co
shape and keep its alpha, so an anti-aliased edge mixes the style and the background only. Layer
opacity fades the whole stack; Knockout and the R/G/B channel switches apply at composite time. Styled pixels are cached separately from the layer content, so editing a style
reuses the cached source/masks/effects. Global Light is one setting per comp, mirrored into every
layer's Blending Options and kept in step after each edit.
layer's Blending Options and kept in step after each edit. Blending Options can be edited in the Layer Style dialog
before any style is added: the first change creates the Layer Styles group with Blending Options
only (`layer.style.add` with `blendingOptions`), in the same undo step as the change.
**Paint and Puppet** are effects whose instances carry nested groups. Paint (`effects::paint`)
holds Brush / Clone / Eraser strokes (Path, Stroke Options, Transform, hidden Duration span);