M13.5: Layer Style dialog

LYR-9: Layer > Layer Styles > Layer Style Options... (layer.style.options)
and choosing a style from that menu open a Layer Style dialog: style list
with check boxes (Blending Options first) and the selected style's controls
built from its layerStyles properties, edited live through prop.set (one
undo step per control); Cancel rolls back everything done since it opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 00:11:19 -04:00
parent 1ebfef77c4
commit d2ccf737a8
9 changed files with 288 additions and 1 deletions

View File

@ -16,6 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
// Application.
fe!("app.about", "About EffectCraft...", [], None, "{}", always),
fe!("layer.style.options", "Layer Style Options...", ["Layer", "Layer Styles"], None, "{layer?, style?: blendingOptions|dropShadow|…}", has_layers),
fe!(
"app.settings",
"Settings...",

View File

@ -674,6 +674,8 @@ Layer
Matte with Layer Above | layer.trackMatte {"op":"above"}
Matte with Layer Below | layer.trackMatte {"op":"below"}
Layer Styles
Layer Style Options... | layer.style.options
---
Convert to Editable Styles | layer.style.convertToEditable
Show All | layer.style.showAll
Remove All | layer.style.removeAll

View File

@ -271,7 +271,8 @@ fn tate_chu_yoko_via_set_text_with_undo_and_serde() {
let lay = effectcraft_text::layout_doc(&doc);
assert!((lay.glyphs[1].origin.y - lay.glyphs[2].origin.y).abs() < 1e-6, "the digits share a row");
let back = roundtrip(&s);
let effectcraft_keyframe::Value::Text(d) = &back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value
let effectcraft_keyframe::Value::Text(d) =
&back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value
else {
panic!()
};

View File

@ -71,6 +71,8 @@ pub enum Dialog {
Marker,
/// Layer ▸ Pre-compose.
Precompose,
/// Layer ▸ Layer Styles ▸ Layer Style Options…
LayerStyles,
}
/// Host hooks provided by the native app (file pickers etc.).

View File

@ -153,6 +153,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::dialogs_3d::route(app, id, &params)? {
return Ok(Value::Null);
}
// Layer ▸ Layer Styles ▸ <style> from the menu adds the style and opens the dialog on it.
if crate::panels::layer_styles_dialog::route(app, id, &params)? {
return Ok(Value::Null);
}
// Legacy per-panel / per-workspace ids (`window.panel.Project`, `window.workspace.default`).
if let Some(rest) = id.strip_prefix("window.panel.") {
return frontend(app, ctx, "window.panel", json!({"panel": rest}));
@ -452,6 +456,10 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.dialog = Some(crate::Dialog::About);
Value::Null
}
"layer.style.options" => {
crate::panels::layer_styles_dialog::open(app, &p)?;
Value::Null
}
"app.settings" => {
let page = p.get("page").and_then(Value::as_str).unwrap_or("general");
crate::panels::settings::open(app, effectcraft_engine::prefs::page_id(page).unwrap_or("general"));

View File

@ -42,6 +42,8 @@ pub struct DialogState {
pub marker: super::markers_ui::MarkerDraft,
/// Pre-compose dialog.
pub precompose: super::precomp::PrecomposeDraft,
/// Layer Style dialog.
pub layer_style: super::layer_styles_dialog::LayerStyleState,
}
pub fn open_new_comp(app: &mut EffectcraftApp) {
@ -124,6 +126,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::TrackOptions => super::tracker::options_dialog(app, ctx, &t),
Dialog::Marker => super::markers_ui::dialog(app, ctx, &t),
Dialog::Precompose => super::precomp::dialog(app, ctx, &t),
Dialog::LayerStyles => super::layer_styles_dialog::show(app, ctx, &t),
Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t),
Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t),
}

View File

@ -0,0 +1,226 @@
//! Layer ▸ Layer Styles ▸ Layer Style Options… (and choosing a style from that menu): a Layer
//! Style dialog with the style list on the left (a check box per style, Blending Options first)
//! and the selected style's controls on the right. Every control edits the ordinary
//! `layerStyles/<style>/<property>` property, so the viewer previews live; Cancel rolls the
//! edits made in the dialog back, OK keeps them (one undo step per control drag).
use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::styles::{BLENDING, STYLES};
use effectcraft_engine::project::{Layer, LayerId, ParamUi, Property};
use egui::{Align2, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Dialog state: layer, selected list entry and the undo depth when it opened.
#[derive(Clone, Debug, Default)]
pub struct LayerStyleState {
pub layer: u64,
pub selected: String,
pub undo_mark: usize,
}
/// Open the dialog on the first selected (or given) layer, showing `style` (a style id;
/// Blending Options when none).
pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
let comp = app.session.active_comp().ok_or("no composition is open")?;
let layer = params
.get("layer")
.and_then(Value::as_u64)
.or_else(|| app.session.state.selected_layers.first().map(|l| l.0))
.filter(|l| comp.layer(LayerId(*l)).is_some())
.ok_or("select a layer first")?;
let style = params.get("style").and_then(Value::as_str).unwrap_or(BLENDING).to_string();
if style != BLENDING && !STYLES.iter().any(|s| s.0 == style) {
return Err(format!("unknown layer style `{style}`"));
}
app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len() };
app.dialog = Some(Dialog::LayerStyles);
Ok(())
}
/// Menu routing: choosing one of the Layer ▸ Layer Styles ▸ <style> items (no parameters) adds
/// the style as usual and opens the dialog on it.
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
let Some(style) = id.strip_prefix("layer.style.").filter(|s| STYLES.iter().any(|x| x.0 == *s)) else { return Ok(false) };
if !params.as_object().is_none_or(|m| m.is_empty()) {
return Ok(false);
}
let mark = app.session.history.undo.len();
app.session.execute(id, json!({})).map_err(|e| e.to_string())?;
open(app, &json!({"style": style}))?;
// Cancel also removes the style just added.
app.dialog_state.layer_style.undo_mark = mark;
Ok(true)
}
fn layer(app: &EffectcraftApp) -> Option<Layer> {
app.session.active_comp()?.layer(LayerId(app.dialog_state.layer_style.layer)).cloned()
}
/// One control row for a property. Returns the new value when it changed.
fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
match (v, &p.ui) {
(KV::Bool(b), _) => {
let mut on = *b;
ui.checkbox(&mut on, "").changed().then(|| json!(on))
}
(KV::Enum(i), ParamUi::Popup { options }) => {
let mut sel = *i as usize;
egui::ComboBox::from_id_salt(("ls-popup", p.uid)).selected_text(options.get(sel).cloned().unwrap_or_default()).show_ui(ui, |ui| {
for (k, o) in options.iter().enumerate() {
ui.selectable_value(&mut sel, k, o);
}
});
(sel != *i as usize).then(|| json!(sel))
}
(KV::Scalar(x), ui_kind) => {
let mut y = *x;
let r = match ui_kind {
ParamUi::Slider { slider_min, slider_max, decimals, .. } => {
ui.add(egui::Slider::new(&mut y, *slider_min..=*slider_max).clamping(egui::SliderClamping::Never).max_decimals(*decimals as usize))
}
ParamUi::Percent => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("%")),
ParamUi::Angle => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("°")),
ParamUi::Pixels => ui.add(egui::DragValue::new(&mut y).speed(0.25).suffix(" px")),
_ => ui.add(egui::DragValue::new(&mut y).speed(0.1)),
};
(r.changed() && y != *x).then(|| json!(y))
}
(KV::Color(c), _) => {
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32);
let changed = egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed();
changed.then(|| json!([rgba.r(), rgba.g(), rgba.b()]))
}
(KV::Gradient(_), _) => {
ui.label(egui::RichText::new("Edit in the Timeline").weak());
None
}
_ => {
ui.label(egui::RichText::new(format!("{v:?}").chars().take(24).collect::<String>()).weak());
None
}
}
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let Some(l) = layer(app) else {
app.dialog = None;
return;
};
let lid = l.id.0;
let styles = l.layer_styles().cloned();
let sel = app.dialog_state.layer_style.selected.clone();
let time = app.session.time();
let lt = l.layer_time(time);
let mut actions: Vec<(String, Value)> = Vec::new();
let mut close: Option<bool> = None;
super::dialogs::modal(ctx, &format!("Layer Style — {}", l.name), vec2(720.0, 520.0), t, |ui| {
ui.horizontal_top(|ui| {
// Style list.
ui.vertical(|ui| {
ui.set_width(200.0);
let mut entries = vec![(BLENDING, "Blending Options")];
entries.extend(STYLES.iter().copied());
for (id, name) in entries {
ui.horizontal(|ui| {
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 g.is_none() {
actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id})));
} else {
actions.push(("layer.style.toggle".into(), json!({"layer": lid, "style": id, "value": on})));
}
}
} else {
ui.add_space(24.0);
}
let r = ui.selectable_label(sel == id, name);
if r.clicked() {
app.dialog_state.layer_style.selected = id.to_string();
}
let rect = r.rect;
app.auto.add(&format!("dialog.layerStyle.list.{id}"), rect, name);
});
}
});
ui.separator();
// Controls of the selected style.
ui.vertical(|ui| {
let name = if sel == BLENDING { "Blending Options" } else { STYLES.iter().find(|s| s.0 == sel).map(|s| s.1).unwrap_or("") };
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 {
ui.label(egui::RichText::new("Not applied: tick it in the list to add it.").weak());
return;
};
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
egui::Grid::new("ls-grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
g.walk("", &mut |_, p| {
if matches!(p.ui, ParamUi::Hidden) {
return;
}
let lbl = ui.label(&p.name);
let v = p.value_at(lt);
if let Some(nv) = control(ui, p, &v) {
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();
app.auto.add(&format!("dialog.layerStyle.prop.{sel}/{path}"), lbl.rect, &p.name);
ui.end_row();
});
});
});
});
});
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = ui.button("OK");
app.auto.add("dialog.layerStyle.ok", ok.rect, "OK");
if ok.clicked() {
close = Some(true);
}
let cancel = ui.button("Cancel");
app.auto.add("dialog.layerStyle.cancel", cancel.rect, "Cancel");
if cancel.clicked() {
close = Some(false);
}
let _ = Align2::LEFT_CENTER;
});
});
for (id, p) in actions {
if let Err(e) = app.session.execute(&id, p) {
app.ui.status = e.to_string();
}
}
match close {
Some(true) => finish(app, true),
Some(false) => finish(app, false),
None => {}
}
}
/// Show another entry of the list (`blendingOptions` or a style id).
pub fn select(app: &mut EffectcraftApp, style: &str) {
app.dialog_state.layer_style.selected = style.to_string();
}
/// Close the dialog: OK keeps the edits, Cancel undoes everything done since it opened.
pub fn finish(app: &mut EffectcraftApp, ok: bool) {
if !ok {
let mark = app.dialog_state.layer_style.undo_mark;
while app.session.history.undo.len() > mark {
if !app.session.undo() {
break;
}
}
}
app.dialog = None;
}

View File

@ -15,6 +15,7 @@ pub mod home;
pub mod info;
pub mod key_dialogs;
pub mod layer_panel;
pub mod layer_styles_dialog;
pub mod markers_ui;
pub mod misc;
pub mod paint_panels;

View File

@ -60,6 +60,49 @@ fn timeline_outline_columns_scroll_horizontally() {
assert!(h.state().auto.find("timeline.outlineScroll").is_none());
}
fn styles(h: &mut Harness<'_, EffectcraftApp>) -> Vec<String> {
let v = h.state_mut().session.execute("layer.style.list", json!({})).unwrap();
v["styles"].as_array().unwrap().iter().map(|s| s["style"].as_str().unwrap().to_string()).collect()
}
#[test]
fn layer_style_dialog_adds_previews_and_cancels() {
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();
// Layer ▸ Layer Styles ▸ Drop Shadow (from the menu): adds the style and opens the dialog on it.
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles));
assert_eq!(styles(&mut h), ["dropShadow"]);
assert!(h.state().auto.find("dialog.layerStyle.prop.dropShadow/distance").is_some(), "the style's controls are shown");
// Live preview: the controls edit the real property (here through the same command).
h.state_mut().session.execute("prop.set", json!({"layer": lid, "path": "layerStyles/dropShadow/distance", "value": 25, "merge": "x"})).unwrap();
// Pick Stroke in the list (its page: not applied), then Cancel: everything done in the
// dialog is rolled back. (Dialog buttons are driven through the dialog's functions:
// kittest pointer events don't reach Foreground areas here.)
effectcraft_ui_egui::panels::layer_styles_dialog::select(h.state_mut(), "stroke");
h.run_steps(2);
assert_eq!(h.state().auto.find("dialog.layerStyle.page").unwrap().label, "stroke");
assert!(h.state().auto.find("dialog.layerStyle.prop.dropShadow/distance").is_none());
assert!(h.state().auto.find("dialog.layerStyle.cancel").is_some());
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false);
h.run_steps(2);
assert!(h.state().dialog.is_none());
assert!(styles(&mut h).is_empty(), "Cancel removes the style added from the menu");
// Layer Style Options… on Stroke, add it (the list check box runs layer.style.add), OK keeps it.
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.options", json!({"style": "stroke"})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("dialog.layerStyle.prop.stroke/size").is_none(), "not applied yet");
h.state_mut().session.execute("layer.style.add", json!({"style": "stroke"})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("dialog.layerStyle.prop.stroke/size").is_some(), "its controls appear");
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true);
assert!(h.state().dialog.is_none());
assert_eq!(styles(&mut h), ["stroke"]);
}
#[test]
fn project_panel_columns_scroll_horizontally() {
let mut h = harness(1400.0);