G2: Effect Gallery six categories and per-filter settings

Limit gallery folders to the six Photoshop-style sets and edit stack params via the existing effect dialog body before apply.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-22 06:52:07 +07:00
committed by bflatastic
co-authored by Cursor
parent ae3a8fe4c3
commit dd9c285252
+152 -21
View File
@@ -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<String, Value>,
}
/// Open the gallery on the current selection.
@@ -48,7 +45,10 @@ pub fn open(app: &mut VectorcraftApp, _p: &Value) -> Result<Value, String> {
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<EffectInfo> {
out
}
fn default_params(id: &str) -> Map<String, Value> {
match vectorcraft_effects::effect_info(id).map(|e| e.defaults) {
Some(Value::Object(m)) => m,
_ => Map::new(),
}
}
fn merged_params(id: &str, stored: &Map<String, Value>) -> Map<String, Value> {
match vectorcraft_effects::merged_params(id, &Value::Object(stored.clone())) {
Value::Object(m) => m,
_ => default_params(id),
}
}
fn stack_of(d: &Dialog) -> Vec<StackEntry> {
d.fields
.get("stack")
@@ -71,7 +85,13 @@ fn stack_of(d: &Dialog) -> Vec<StackEntry> {
.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<StackEntry> {
}
fn write_stack(d: &mut Dialog, stack: &[StackEntry]) {
let rows: Vec<Value> = stack.iter().map(|e| json!({ "effect": e.effect, "visible": e.visible })).collect();
let rows: Vec<Value> = 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<String, Value> {
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<String, Value>) {
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<String, Value>) {
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<String, Value>) {
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<String, Value>) {
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, &param_effect, &mut param_values);
});
write_param_target(d, &mut stack, stack_sel, &sel, &param_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, &param_effect, 160.0);
ui.label(egui::RichText::new(effect_label(&param_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<Value, String> {
@@ -275,7 +402,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
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);