diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 09bc13bb..fb381cfd 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) { @@ -329,6 +329,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)) @@ -381,8 +399,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); @@ -394,7 +413,16 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa paint_kind(app, shape_layers, key, next); } 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"); +} diff --git a/docs/gaps.md b/docs/gaps.md index eb3a8c34..e621ee87 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -145,6 +145,11 @@ The most important missing piece: it turns every other estimate here into a meas tests, checked headlessly on Windows, drag a selected Gradient Fill's start and end points in the Composition viewer: one undo step, the layer stays, a point inside a moved and scaled group converts to the group's space, and the handles show only for a selected gradient. +- Regression evidence for the toolbar's gradient swatches (#612, reported on Discord): the + `ui_toolbar_gradient` input tests, checked headlessly on Windows, click a Linear Fill swatch and + a Radial Stroke swatch on a selected shape layer and get the Gradient Editor on its Gradient Fill + / Gradient Stroke Colors (OK saves); without such a gradient the swatch still opens the options, + and the words, Solid swatches and Alt-click are unchanged. - Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template