diff --git a/crates/ui-egui/src/dialogs/effect_gallery.rs b/crates/ui-egui/src/dialogs/effect_gallery.rs index 81a379b1c..25a12d706 100644 --- a/crates/ui-egui/src/dialogs/effect_gallery.rs +++ b/crates/ui-egui/src/dialogs/effect_gallery.rs @@ -2,10 +2,10 @@ //! apply it as separate appearance effects in one undo step. use egui::{Color32, Rect, Sense, Stroke}; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use vectorcraft_effects::{EffectInfo, PIXEL_EFFECTS}; -use super::DialogSpec; +use super::{DialogSpec, effect, form}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; @@ -13,18 +13,14 @@ use crate::{VectorcraftApp, widgets}; /// Dialog kind for the Effect Gallery. pub const KIND: &str = "effectGallery"; -/// Raster effect submenus shown in the gallery (same paths as the Effect menu). -const CATEGORIES: [(&str, &[&str]); 10] = [ +/// Raster effect submenus in the gallery (the six Photoshop-style folders it holds). +const CATEGORIES: [(&str, &[&str]); 6] = [ ("Artistic", &["Effect", "Artistic"]), - ("Blur", &["Effect", "Blur"]), ("Brush Strokes", &["Effect", "Brush Strokes"]), ("Distort", &["Effect", "Distort"]), - ("Pixelate", &["Effect", "Pixelate"]), - ("Sharpen", &["Effect", "Sharpen"]), ("Sketch", &["Effect", "Sketch"]), ("Stylize", &["Effect", "Stylize"]), ("Texture", &["Effect", "Texture"]), - ("Video", &["Effect", "Video"]), ]; pub(super) const SPEC: DialogSpec = DialogSpec { @@ -40,6 +36,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { struct StackEntry { effect: String, visible: bool, + params: Map, } /// Open the gallery on the current selection. @@ -48,7 +45,10 @@ pub fn open(app: &mut VectorcraftApp, _p: &Value) -> Result { return Err(tl!("Select artwork to apply effects").into()); } let selected = effects_in_category(0).first().map(|e| e.id).unwrap_or(""); - app.ui.dialog = Some(Dialog::new(KIND, json!({ "category": 0, "selected": selected, "stack": [], "stackSelected": 0 }))); + app.ui.dialog = Some(Dialog::new( + KIND, + json!({ "category": 0, "selected": selected, "stack": [], "stackSelected": 0, "stackFocus": false }), + )); Ok(Value::Null) } @@ -62,6 +62,20 @@ fn effects_in_category(cat: usize) -> Vec { out } +fn default_params(id: &str) -> Map { + match vectorcraft_effects::effect_info(id).map(|e| e.defaults) { + Some(Value::Object(m)) => m, + _ => Map::new(), + } +} + +fn merged_params(id: &str, stored: &Map) -> Map { + match vectorcraft_effects::merged_params(id, &Value::Object(stored.clone())) { + Value::Object(m) => m, + _ => default_params(id), + } +} + fn stack_of(d: &Dialog) -> Vec { d.fields .get("stack") @@ -71,7 +85,13 @@ fn stack_of(d: &Dialog) -> Vec { .filter_map(|row| { let effect = row.get("effect").and_then(Value::as_str)?; let visible = row.get("visible").and_then(Value::as_bool).unwrap_or(true); - Some(StackEntry { effect: effect.to_string(), visible }) + let params = row + .get("params") + .and_then(Value::as_object) + .cloned() + .map(|m| merged_params(effect, &m)) + .unwrap_or_else(|| default_params(effect)); + Some(StackEntry { effect: effect.to_string(), visible, params }) }) .collect() }) @@ -79,7 +99,10 @@ fn stack_of(d: &Dialog) -> Vec { } fn write_stack(d: &mut Dialog, stack: &[StackEntry]) { - let rows: Vec = stack.iter().map(|e| json!({ "effect": e.effect, "visible": e.visible })).collect(); + let rows: Vec = stack + .iter() + .map(|e| json!({ "effect": e.effect, "visible": e.visible, "params": e.params })) + .collect(); d.fields.insert("stack".into(), Value::Array(rows)); } @@ -95,6 +118,82 @@ fn set_selected(d: &mut Dialog, id: &str) { d.fields.insert("selected".into(), json!(id)); } +fn stack_focus(d: &Dialog) -> bool { + d.fields.get("stackFocus").and_then(Value::as_bool).unwrap_or(false) +} + +fn set_stack_focus(d: &mut Dialog, on: bool) { + d.fields.insert("stackFocus".into(), json!(on)); +} + +fn browse_params(d: &Dialog) -> Map { + let effect = d.fields.get("browseEffect").and_then(Value::as_str).unwrap_or(""); + let stored = d.fields.get("browseParams").and_then(Value::as_object).cloned().unwrap_or_default(); + if effect.is_empty() { + stored + } else { + merged_params(effect, &stored) + } +} + +fn set_browse_params(d: &mut Dialog, effect: &str, params: Map) { + d.fields.insert("browseEffect".into(), json!(effect)); + d.fields.insert("browseParams".into(), Value::Object(params)); +} + +/// The effect whose parameters the panel edits, and its values. +fn param_target(d: &Dialog, stack: &[StackEntry], stack_sel: usize, browse: &str) -> (String, Map) { + if stack_focus(d) { + let entry = stack.get(stack_sel); + if let Some(e) = entry { + return (e.effect.clone(), e.params.clone()); + } + } + let id = browse.to_string(); + let params = if d.fields.get("browseEffect").and_then(Value::as_str) == Some(browse) { + browse_params(d) + } else { + default_params(browse) + }; + (id, params) +} + +fn write_param_target(d: &mut Dialog, stack: &mut [StackEntry], stack_sel: usize, browse: &str, effect: &str, params: Map) { + if stack_focus(d) { + if let Some(e) = stack.get_mut(stack_sel) { + if e.effect == effect { + e.params = params; + } + } + return; + } + if browse == effect { + set_browse_params(d, effect, params); + } +} + +/// Reuse the live effect dialog's fields for `effect_id` and `params`. +fn effect_param_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, effect_id: &str, params: &mut Map) { + if effect_id.is_empty() { + return; + } + let label = vectorcraft_effects::effect_info(effect_id) + .map(|e| e.label.trim_end_matches('…').to_string()) + .unwrap_or_else(|| effect_id.to_string()); + let mut fields = params.clone(); + fields.insert("__effect".into(), json!(effect_id)); + fields.insert("__label".into(), json!(label)); + fields.insert("preview".into(), json!(false)); + let mut temp = Dialog::new("effect", Value::Object(fields)); + (effect::SPEC.body)(app, ui, &mut temp); + if let Value::Object(m) = form::params(&temp) { + *params = m; + } + if let Value::Object(m) = vectorcraft_effects::upgrade_params(effect_id, &Value::Object(params.clone())) { + *params = m; + } +} + /// A small procedural thumbnail from the effect id (no copied artwork). fn thumb(ui: &mut egui::Ui, id: &str, size: f32) { let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), Sense::hover()); @@ -128,7 +227,7 @@ fn effect_label(id: &str) -> String { vectorcraft_effects::effect_info(id).map(|e| tl!(e.label).trim_end_matches('…').to_string()).unwrap_or_else(|| id.to_string()) } -fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); let cat = category_ix(d); let effects = effects_in_category(cat); @@ -138,6 +237,20 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { set_selected(d, &sel); } + let mut stack = stack_of(d); + let mut stack_sel = d.fields.get("stackSelected").and_then(Value::as_u64).unwrap_or(0) as usize; + if stack_sel >= stack.len() && !stack.is_empty() { + stack_sel = stack.len() - 1; + } + let (param_effect, mut param_values) = param_target(d, &stack, stack_sel, &sel); + if !param_effect.is_empty() { + widgets::subheader(ui, tl!("Settings")); + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + effect_param_fields(app, ui, ¶m_effect, &mut param_values); + }); + write_param_target(d, &mut stack, stack_sel, &sel, ¶m_effect, param_values); + } + ui.horizontal_top(|ui| { ui.vertical(|ui| { widgets::subheader(ui, tl!("Categories")); @@ -152,6 +265,7 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("category".into(), json!(i)); let first = effects_in_category(i).first().map(|e| e.id).unwrap_or(""); set_selected(d, first); + set_stack_focus(d, false); } } }); @@ -168,25 +282,30 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let resp = ui.add(egui::Button::selectable(picked, name)); if resp.clicked() { set_selected(d, e.id); + set_stack_focus(d, false); } if resp.double_clicked() { set_selected(d, e.id); + set_stack_focus(d, false); add_selected(d, e.id); + stack = stack_of(d); } }); } }); if widgets::flat_button(ui, tl!("Add to Stack"), 120.0).clicked() && !sel.is_empty() { add_selected(d, &sel); + stack = stack_of(d); } }); ui.add_space(8.0); ui.vertical(|ui| { widgets::subheader(ui, tl!("Preview")); ui.set_width(200.0); - if !sel.is_empty() { - thumb(ui, &sel, 160.0); - ui.label(egui::RichText::new(effect_label(&sel)).color(t.text_strong)); + let (param_effect, _) = param_target(d, &stack, stack_sel, &sel); + if !param_effect.is_empty() { + thumb(ui, ¶m_effect, 160.0); + ui.label(egui::RichText::new(effect_label(¶m_effect)).color(t.text_strong)); } else { ui.label(egui::RichText::new(tl!("Choose a filter")).color(t.text_dim)); } @@ -195,7 +314,6 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_space(10.0); widgets::subheader(ui, tl!("Applied Filters")); - let mut stack = stack_of(d); let mut stack_sel = d.fields.get("stackSelected").and_then(Value::as_u64).unwrap_or(0) as usize; if stack_sel >= stack.len() && !stack.is_empty() { stack_sel = stack.len() - 1; @@ -238,7 +356,10 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); match op { Some(StackOp::Toggle(i)) if stack.get(i).is_some() => stack[i].visible = !stack[i].visible, - Some(StackOp::Select(i)) => stack_sel = i, + Some(StackOp::Select(i)) => { + stack_sel = i; + set_stack_focus(d, true); + } Some(StackOp::Up(i)) if i > 0 => { stack.swap(i, i - 1); stack_sel = i - 1; @@ -261,10 +382,16 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } fn add_selected(d: &mut Dialog, id: &str) { + let params = if !stack_focus(d) && d.fields.get("browseEffect").and_then(Value::as_str) == Some(id) { + browse_params(d) + } else { + default_params(id) + }; let mut stack = stack_of(d); - stack.push(StackEntry { effect: id.to_string(), visible: true }); + stack.push(StackEntry { effect: id.to_string(), visible: true, params }); write_stack(d, &stack); d.fields.insert("stackSelected".into(), json!(stack.len().saturating_sub(1))); + set_stack_focus(d, true); } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { @@ -275,7 +402,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } let mut commands = Vec::with_capacity(stack.len() * 2); for (i, entry) in stack.iter().enumerate() { - let params = vectorcraft_effects::effect_info(&entry.effect).map(|e| e.defaults.clone()).unwrap_or(json!({})); + let params = Value::Object(entry.params.clone()); commands.push(json!({ "command": "effect.apply", "params": { "effect": entry.effect, "params": params } })); if !entry.visible { commands.push(json!({ "command": "effect.setParams", "params": { "index": i, "visible": false } })); @@ -314,7 +441,10 @@ mod tests { let d = app.ui.dialog.as_mut().unwrap(); d.fields.insert( "stack".into(), - json!([{ "effect": "blur.gaussian", "visible": true }, { "effect": "stylize.glowingEdges", "visible": false }]), + json!([ + { "effect": "artistic.cutout", "visible": true, "params": { "levels": 3 } }, + { "effect": "stylize.glowingEdges", "visible": false, "params": { "edgeWidth": 2 } } + ]), ); frame(&mut app); let steps = app.session.active().unwrap().history.undo.len(); @@ -322,7 +452,8 @@ mod tests { let st = app.session.active().unwrap(); let n = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap(); assert_eq!(n.appearance.effects.len(), 2); - assert_eq!(n.appearance.effects[0].id, "blur.gaussian"); + assert_eq!(n.appearance.effects[0].id, "artistic.cutout"); + assert_eq!(n.appearance.effects[0].params.get("levels").and_then(|v| v.as_i64()), Some(3)); assert_eq!(n.appearance.effects[1].id, "stylize.glowingEdges"); assert!(!n.appearance.effects[1].visible); assert_eq!(st.history.undo.len(), steps + 1);