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:
parent
1ebfef77c4
commit
d2ccf737a8
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.).
|
||||
|
|
|
|||
|
|
@ -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, ¶ms)? {
|
||||
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, ¶ms)? {
|
||||
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"));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue