From 3d7f9284656db96a4fade189c3fde7f227d05650 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 11 Oct 2026 14:46:45 -0400 Subject: [PATCH] Shapes: the toolbar's gradient swatches open the Gradient Editor (#612) With Fill or Stroke set to Linear or Radial Gradient, clicking its toolbar swatch opened Fill / Stroke Options, so the gradient's colours couldn't be edited from the toolbar as in After Effects (reported on Discord, EffectCraft 0.8.0 on Windows 11). The swatch now opens the Gradient Editor (prop.editGradient) on the selected shape layer's first Gradient Fill or Gradient Stroke Colors, in contents order through nested groups; OK sets that property. With no shape layer selected, or none with that gradient, the swatch opens the options as before (the kind new shapes get: the toolbar keeps no gradient stops of its own yet). The words still open the options, a Solid Color swatch the colour picker, and Alt-click still steps through the kinds. Tests (ui_toolbar_gradient): a Linear Fill swatch opens the editor on the layer's Gradient Fill Colors and OK saves an added stop; a Radial Stroke swatch edits the Gradient Stroke, not the fill; without a selected gradient the options open; the words, Solid swatches and Alt-click are unchanged. The two gradient-swatch tests fail without this change. --- crates/ui-egui/src/header.rs | 34 ++++- crates/ui-egui/tests/ui_toolbar_gradient.rs | 135 ++++++++++++++++++++ 2 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/ui_toolbar_gradient.rs diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1260c9c9..14e676fc 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -12,7 +12,7 @@ use crate::widgets; use crate::{Dialog, EffectcraftApp}; use effectcraft_color::BlendMode; use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; -use effectcraft_engine::project::{LayerId, LayerSource}; +use effectcraft_engine::project::{LayerId, LayerSource, PropGroup}; use effectcraft_engine::viewer as vw; pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { @@ -301,6 +301,24 @@ fn tool_options(app: &mut EffectcraftApp, v: serde_json::Value) { } } +/// The Colors property of the first Gradient Fill (`gfill`) or Gradient Stroke (`gstroke`) on the +/// shape `layers`, in contents order through nested groups: (layer, property uid). The toolbar's +/// gradient swatch edits it in the Gradient Editor. +fn gradient_colors(app: &EffectcraftApp, layers: &[u64], paint: &str) -> Option<(u64, u64)> { + fn find(g: &PropGroup, paint: &str, depth: usize) -> Option { + if depth >= 16 { + return None; + } + g.groups().filter(|c| c.enabled).find_map(|c| match c.match_id.as_str() { + m if m == paint => c.get("colors").map(|p| p.uid), + "group" | "contents" => find(c, paint, depth + 1), + _ => None, + }) + } + let comp = app.session.active_comp()?; + layers.iter().find_map(|&l| comp.layer(LayerId(l)).and_then(|layer| layer.props.sub("contents")).and_then(|c| find(c, paint, 0)).map(|u| (l, u))) +} + /// The colour picker of the Fill or Stroke swatch. fn picker_id(key: &str) -> egui::Id { egui::Id::new(("tool-pick", key)) @@ -353,8 +371,9 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa if wresp.clicked() { widgets::open_popup(ui, opts); } - // The swatch picks a Solid Color's colour and opens the options for the other kinds; - // Alt-click steps through the kinds (as in After Effects). + // The swatch picks a Solid Color's colour, edits a Linear or Radial Gradient's colours in + // the Gradient Editor (the selected shape layer's Gradient Fill / Gradient Stroke), and + // opens the options otherwise; Alt-click steps through the kinds (as in After Effects). let sr = Rect::from_min_size(pos2(wr.max.x + 6.0, cy - 8.0), vec2(20.0, 16.0)); let sresp = ui.interact(sr, egui::Id::new(("tool-paint-swatch", key)), Sense::click()).on_hover_text(format!("{title}: {}", tp.kind.label())); paint_swatch(p, sr, &tp, sresp.hovered(), &t); @@ -367,7 +386,16 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa tool_options(app, json!({k: next.id()})); } else if tp.kind == PaintKind::Solid { widgets::open_popup(ui, picker_id(key)); + } else if let Some((layer, prop)) = matches!(tp.kind, PaintKind::Linear | PaintKind::Radial) + .then(|| gradient_colors(app, shape_layers, if key == "fill" { "gfill" } else { "gstroke" })) + .flatten() + { + let ctx = ui.ctx().clone(); + if let Err(e) = crate::menus::invoke(app, &ctx, "prop.editGradient", json!({"layer": layer, "prop": prop})) { + app.ui.status = e; + } } else { + // No selected shape layer with that gradient: its options (new shapes' kind). widgets::open_popup(ui, opts); } } diff --git a/crates/ui-egui/tests/ui_toolbar_gradient.rs b/crates/ui-egui/tests/ui_toolbar_gradient.rs new file mode 100644 index 00000000..575a4b3e --- /dev/null +++ b/crates/ui-egui/tests/ui_toolbar_gradient.rs @@ -0,0 +1,135 @@ +//! The toolbar's Fill / Stroke swatch with Linear or Radial Gradient (#612, reported on Discord): +//! it opens the Gradient Editor on the selected shape layer's Gradient Fill / Gradient Stroke +//! Colors, as in After Effects, instead of Fill / Stroke Options. The words still open the +//! options, a Solid Color swatch the colour picker, Alt-click still steps through the kinds, and +//! with no such gradient selected the swatch opens the options as before. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::state::Tool; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +/// The Colors uid of the first `paint` ("gfill" / "gstroke") group in `g`, through nested groups. +fn colors(g: &PropGroup, paint: &str) -> Option { + g.groups().find_map(|x| if x.match_id == paint { x.get("colors").map(|p| p.uid) } else { colors(x, paint) }) +} + +/// A rectangle shape layer (selected unless `select` is false) with `items` added to its +/// contents, and the toolbar set with `tool` (shape.toolOptions). +fn setup(items: &[&str], tool: serde_json::Value, select: bool) -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + for kind in items { + s.execute("layer.addShapeItem", json!({"layer": l, "kind": kind})).unwrap(); + } + s.execute("shape.toolOptions", tool).unwrap(); + s.state.selected_layers = if select { vec![LayerId(l)] } else { vec![] }; + s.state.selected_props.clear(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + // With no shape layer selected, Fill / Stroke show while a shape tool is active. + if !select { + h.state_mut().ui.tool = Tool::Rectangle; + } + h.run_steps(4); + (h, l) +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, modifiers: Modifiers) { + let p = center(h, id); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +fn layer_colors(h: &Harness<'_, EffectcraftApp>, l: u64, paint: &str) -> u64 { + colors(&h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props, paint).expect("the gradient") +} + +/// The options popup is open (its kind buttons are in the automation tree). +fn options_open(h: &Harness<'_, EffectcraftApp>, key: &str) -> bool { + h.state().auto.find(&format!("header.{key}Options.solid")).is_some() +} + +/// The reported bug: a Linear Gradient Fill swatch opened Fill Options. It opens the Gradient +/// Editor on the selected layer's Gradient Fill Colors, and OK sets them. +#[test] +fn a_gradient_fill_swatch_opens_the_gradient_editor() { + let (mut h, l) = setup(&["gfill"], json!({"fillType": "linear"}), true); + let uid = layer_colors(&h, l, "gfill"); + click(&mut h, "header.fill", Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "the swatch opens the Gradient Editor, not Fill Options"); + assert!(!options_open(&h, "fill"), "and not the options"); + let target = h.state().gradient_draft().target.clone().expect("a target"); + assert_eq!((target.layer, target.prop), (l, uid), "on the layer's Gradient Fill Colors"); + // Edit and save it: the property really changes. + let bar = h.state().auto.find("dialog.gradient.addColor").unwrap().rect; + let at = pos2(bar[0] + bar[2] * 0.5, bar[1] + bar[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + click(&mut h, "dialog.gradient.ok", Modifiers::NONE); + let v = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find(uid).unwrap().value.clone(); + let Value::Gradient(g) = v else { panic!("not a gradient: {v:?}") }; + assert_eq!(g.colors.len(), 3, "the added stop was saved: {g:?}"); +} + +/// The Stroke swatch with Radial Gradient edits the Gradient Stroke's Colors (not the fill's). +#[test] +fn a_gradient_stroke_swatch_edits_the_gradient_stroke() { + let (mut h, l) = setup(&["gfill", "gstroke"], json!({"strokeType": "radial"}), true); + let uid = layer_colors(&h, l, "gstroke"); + click(&mut h, "header.stroke", Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); + let target = h.state().gradient_draft().target.clone().expect("a target"); + assert_eq!((target.layer, target.prop), (l, uid), "on the Gradient Stroke's Colors"); +} + +/// With no shape layer selected, or one without that gradient, the swatch opens the options (the +/// kind new shapes get) as before. +#[test] +fn without_a_selected_gradient_the_swatch_opens_the_options() { + for (items, select) in [(vec![], true), (vec!["gfill"], false)] { + let (mut h, _) = setup(&items, json!({"fillType": "linear"}), select); + click(&mut h, "header.fill", Modifiers::NONE); + assert_eq!(h.state().dialog, None, "{items:?} selected={select}: no editor"); + assert!(options_open(&h, "fill"), "{items:?} selected={select}: Fill Options"); + } +} + +/// The rest of the toolbar is unchanged: the words open the options, a Solid Color swatch opens the +/// colour picker, and Alt-click steps to the next kind. +#[test] +fn words_solid_swatches_and_alt_click_are_unchanged() { + let (mut h, _) = setup(&["gfill"], json!({"fillType": "linear"}), true); + click(&mut h, "header.fillOptions", Modifiers::NONE); + assert!(options_open(&h, "fill"), "the word opens Fill Options"); + assert_eq!(h.state().dialog, None); + let (mut h, _) = setup(&["gfill"], json!({"fillType": "solid"}), true); + click(&mut h, "header.fill", Modifiers::NONE); + assert!(h.query_by_label("Color field").is_some(), "a Solid Color swatch opens the colour picker"); + assert_eq!(h.state().dialog, None); + let (mut h, _) = setup(&["gfill"], json!({"fillType": "linear"}), true); + click(&mut h, "header.fill", Modifiers::ALT); + assert_eq!(h.state().dialog, None, "Alt-click doesn't open the editor"); + assert_eq!(h.state().session.state.shape_tool.fill.kind.id(), "radial", "it steps Linear → Radial"); +}