diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs index 87fcd141..ecd3ed8e 100644 --- a/crates/engine/src/commands/shape_stroke.rs +++ b/crates/engine/src/commands/shape_stroke.rs @@ -3,13 +3,14 @@ //! commands here set several of them in one undo step and add the groups to strokes saved before //! they existed. Also the toolbar's Fill / Stroke options on selected shape layers. -use effectcraft_keyframe::Value as KV; +use effectcraft_keyframe::{Gradient, Value as KV}; use effectcraft_project::build::{self, EXTRA_DASHES, Ids}; use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::layer::color_p; +use super::shape_tool::{self, PaintKind}; use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p}; use crate::{EngineError, Result, Session, cmd}; @@ -224,15 +225,157 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4 (fill, stroke, width) } -/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and -/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before -/// its fills, as new shapes have it). -#[allow(clippy::too_many_arguments)] -fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option, add_stroke: bool, depth: usize) { +/// What the toolbar's Fill and Stroke set on the selected shape layers. +#[derive(Clone, Copy, Debug, Default)] +struct PaintEdit { + fill: Option<[f64; 4]>, + stroke: Option<[f64; 4]>, + width: Option, + /// A group with a path and no stroke gets one. + add_stroke: bool, + fill_kind: Option, + stroke_kind: Option, +} + +/// The kind of the first Fill and the first Stroke in `layer`'s contents at layer time `lt`: a +/// switched-off or missing one is None (what the toolbar shows for a selected shape layer). +pub fn first_paint_kinds(layer: &Layer, lt: Tick) -> (PaintKind, PaintKind) { + let (mut fill, mut stroke) = (None, None); + let kind = |g: &PropGroup| match g.match_id.as_str() { + _ if !g.enabled => PaintKind::None, + "fill" | "stroke" => PaintKind::Solid, + _ if g.get("type").is_some_and(|p| p.value_at(lt).as_enum() == 1) => PaintKind::Radial, + _ => PaintKind::Linear, + }; + if let Some(c) = layer.props.sub("contents") { + walk_groups(c, &mut |g| match g.match_id.as_str() { + "fill" | "gfill" if fill.is_none() => fill = Some(kind(g)), + "stroke" | "gstroke" if stroke.is_none() => stroke = Some(kind(g)), + _ => {} + }); + } + (fill.unwrap_or(PaintKind::None), stroke.unwrap_or(PaintKind::None)) +} + +/// The bounds (`[x, y, w, h]`) of the path items of one contents level at layer time `lt`, where +/// a new gradient's Start and End Points go; a 100 px square at the origin without paths. +fn path_bounds(c: &PropGroup, lt: Tick) -> [f64; 4] { + let mut acc: Option<[f64; 4]> = None; + for g in c.groups() { + let at = |id: &str| g.get(id).map(|p| p.value_at(lt)); + let b = match g.match_id.as_str() { + "rect" | "ellipse" => { + let [w, h] = at("size").map(|v| v.as_vec2()).unwrap_or([100.0; 2]); + let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]); + Some([x - w.abs() / 2.0, y - h.abs() / 2.0, x + w.abs() / 2.0, y + h.abs() / 2.0]) + } + "star" => { + let r = at("outerRadius").map(|v| v.as_f64().abs()).unwrap_or(100.0); + let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]); + Some([x - r, y - r, x + r, y + r]) + } + "path" => { + let v = at("path"); + let bez = v.as_ref().and_then(|v| v.as_path()).map(effectcraft_path::to_kurbo); + bez.and_then(|b| effectcraft_path::bounds(&[b])).map(|r| [r.x0, r.y0, r.x1, r.y1]) + } + _ => None, + }; + let Some(b) = b.filter(|b| b.iter().all(|v| v.is_finite())) else { continue }; + acc = Some(match acc { + None => b, + Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])], + }); + } + acc.map(|[x0, y0, x1, y1]| [x0, y0, x1 - x0, y1 - y0]).unwrap_or([-50.0, -50.0, 100.0, 100.0]) +} + +/// Fill Options / Stroke Options on one contents level (as in After Effects, with shape layers +/// selected they change the layers' paint): None switches its Fills and Gradient Fills (or +/// Strokes and Gradient Strokes) off; another kind switches them on, turns them into that kind +/// (keeping the properties both kinds have: blend mode, opacity, width, dashes…) and gives a +/// level with paths and no such item one. +fn set_kind(c: &mut PropGroup, ids: &mut Ids, lt: Tick, stroke: bool, kind: PaintKind, e: &PaintEdit) { + let (solid, grad) = if stroke { ("stroke", "gstroke") } else { ("fill", "gfill") }; + let is_item = |g: &PropGroup| g.match_id == solid || g.match_id == grad; + if kind == PaintKind::None { + for n in &mut c.children { + if let Node::Group(g) = n + && is_item(g) + { + g.enabled = false; + } + } + return; + } + let radial = kind == PaintKind::Radial; + let color = (if stroke { e.stroke } else { e.fill }).unwrap_or([1.0; 4]); + let width = e.width.filter(|w| *w > 0.0).unwrap_or(2.0); + let (start, end) = shape_tool::gradient_ends(radial, path_bounds(c, lt)); + let fresh = |ids: &mut Ids| match (kind, stroke) { + (PaintKind::Solid, false) => build::shape_fill(ids, color), + (PaintKind::Solid, true) => build::shape_stroke(ids, color, width), + (_, false) => build::shape_gradient_fill(ids, radial, start, end, Gradient::default()), + (_, true) => build::shape_gradient_stroke(ids, radial, start, end, Gradient::default(), width), + }; + let want = if kind == PaintKind::Solid { solid } else { grad }; + let mut found = false; + for i in 0..c.children.len() { + let Some(Node::Group(g)) = c.children.get(i) else { continue }; + if !is_item(g) { + continue; + } + found = true; + let replacement = (g.match_id != want).then(|| { + let mut new = fresh(ids); + new.uid = g.uid; + new.name = super::effect::unique_name(c, &new.name); + for n in &mut new.children { + if let Some(old) = g.children.iter().find(|o| o.match_id() == n.match_id()) { + *n = old.clone(); + } + } + new + }); + let Some(Node::Group(g)) = c.children.get_mut(i) else { continue }; + match replacement { + Some(new) => *g = new, + None => { + g.enabled = true; + if let Some(t) = g.get_mut("type").filter(|_| want == grad) { + t.set_value_at(lt, KV::Enum(radial as u32)); + } + } + } + } + if !found && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) { + let mut new = fresh(ids); + new.name = super::effect::unique_name(c, &new.name); + // Paths, strokes, then fills, as new shapes have them. + let at = if stroke { + c.children.iter().position(|n| matches!(n.match_id(), "fill" | "gfill")).unwrap_or(c.children.len()) + } else { + c.children.iter().rposition(|n| PATHS.contains(&n.match_id()) || matches!(n.match_id(), "stroke" | "gstroke")).map_or(c.children.len(), |i| i + 1) + }; + c.children.insert(at, new.into()); + } +} + +/// Paint one contents group and its sub-groups: the kinds first, then Fills take `fill`, Strokes +/// `stroke`, Strokes and Gradient Strokes `width`. With `add_stroke`, a group with a path and no +/// stroke gets one (before its fills, as new shapes have it). +fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &PaintEdit, depth: usize) { if depth > MAX_DEPTH { return; } - if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) { + if let Some(k) = e.stroke_kind { + set_kind(c, ids, lt, true, k, e); + } + if let Some(k) = e.fill_kind { + set_kind(c, ids, lt, false, k, e); + } + let (fill, stroke, width) = (e.fill, e.stroke, e.width); + if e.add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) { let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len()); c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into()); } @@ -252,7 +395,7 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str "gstroke" => set(g, "width", width.map(KV::Scalar)), "group" => { if let Some(sub) = g.sub_mut("contents") { - paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1); + paint(sub, ids, lt, e, depth + 1); } } _ => {} @@ -260,10 +403,20 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str } } +/// `p`'s `fillType` / `strokeType` (none|solid|linear|radial). +fn kind_p(p: &Value, key: &str, c: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(PaintKind::from_name).map(Some).ok_or_else(|| bad(c, format!("`{key}`: none|solid|linear|radial"))), + } +} + /// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they -/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's, -/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that -/// have no stroke one. Without values, reports the first layer's paint. +/// change the layers' fills and strokes): `fillType` / `strokeType` switch every Fill / Stroke +/// off (none) or turn them into Solid Color, Linear or Radial Gradient ones (adding one where a +/// path has none), `fill` sets every Fill's colour, `stroke` every Stroke's, `strokeWidth` every +/// stroke's width. A stroke colour or a width above 0 gives the paths that have no stroke one. +/// Without values, reports the first layer's paint. fn fill_stroke(s: &mut Session, p: &Value) -> Result { let c = "shape.fillStroke"; let (cid, lids) = layers_p(s, p)?; @@ -275,8 +428,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result { let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]); let (fill, stroke) = (rgba("fill"), rgba("stroke")); let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0)); - if fill.is_some() || stroke.is_some() || width.is_some() { - let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0); + let (fill_kind, stroke_kind) = (kind_p(p, "fillType", c)?, kind_p(p, "strokeType", c)?); + let add_stroke = stroke_kind != Some(PaintKind::None) && width.map_or(stroke.is_some(), |w| w > 0.0); + let e = PaintEdit { fill, stroke, width, add_stroke, fill_kind, stroke_kind }; + if fill.is_some() || stroke.is_some() || width.is_some() || fill_kind.is_some() || stroke_kind.is_some() { let t = s.time(); s.edit("Fill and Stroke", merge_p(p), |proj, _| { let mut next = proj.next_id; @@ -284,7 +439,7 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); if let Some(contents) = l.props.sub_mut("contents") { - paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0); + paint(contents, &mut Ids(&mut next), lt, &e, 0); } } proj.next_id = next; @@ -292,8 +447,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result { })?; } let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?; - let (fill, stroke, width) = first_paint(l, l.layer_time(s.time())); - Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width})) + let lt = l.layer_time(s.time()); + let (fill, stroke, width) = first_paint(l, lt); + let (fill_kind, stroke_kind) = first_paint_kinds(l, lt); + Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width, "fillType": fill_kind.id(), "strokeType": stroke_kind.id()})) } pub fn specs() -> Vec { @@ -303,7 +460,7 @@ pub fn specs() -> Vec { "Fill and Stroke", [], None, - "{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)", + "{layers?, fillType?: none|solid|linear|radial, strokeType?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth, fillType, strokeType} (first layer)", has_layers, fill_stroke ), diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs index 83ab0f01..a48c32b7 100644 --- a/crates/engine/src/commands/shape_tool.rs +++ b/crates/engine/src/commands/shape_tool.rs @@ -139,12 +139,18 @@ fn blended(mut g: PropGroup, paint: &ToolPaint) -> PropGroup { g } +/// A gradient's Start and End Points across `bounds` (`[x, y, w, h]`): left to right, or out +/// from the centre. +pub(crate) fn gradient_ends(radial: bool, bounds: [f64; 4]) -> ([f64; 2], [f64; 2]) { + let [x, y, w, h] = bounds; + let mid = [x + w / 2.0, y + h / 2.0]; + if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) } +} + /// The Stroke and Fill a new shape gets from `paint`; gradients run across `bounds` (`[x, y, w, /// h]` in the group's space): left to right, or out from the centre. pub(crate) fn paint_items(ids: &mut Ids, paint: &ShapeTool, bounds: [f64; 4]) -> Vec { - let [x, y, w, h] = bounds; - let mid = [x + w / 2.0, y + h / 2.0]; - let ends = |radial: bool| if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) }; + let ends = |radial: bool| gradient_ends(radial, bounds); let mut items = vec![]; let (s, width) = (&paint.stroke, paint.stroke_width); let stroke = match s.kind { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 587379a8..6b0d61ec 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -280,6 +280,57 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() { let _ = s.render(cid, s.time(), Default::default()); } +/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732): +/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its +/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none. +#[test] +fn toolbar_fill_and_stroke_kinds_change_selected_shape_layers() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let items = |s: &Session| -> Vec<(String, bool)> { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap(); + l.props.group("contents/group/contents").unwrap().groups().map(|g| (g.match_id.clone(), g.enabled)).collect() + }; + let kinds = |s: &mut Session| { + let p = s.execute("shape.fillStroke", json!({})).unwrap(); + (p["fillType"].as_str().unwrap().to_string(), p["strokeType"].as_str().unwrap().to_string()) + }; + assert_eq!(kinds(&mut s), ("solid".into(), "none".into())); + let cid = s.active_comp_id().unwrap(); + let centre = |s: &Session| s.render(cid, s.time(), Default::default()).get(200, 150); + let red = centre(&s); + // None switches the fill off: nothing is drawn, and the item stays. + s.execute("shape.fillStroke", json!({"fillType": "none"})).unwrap(); + assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), false)]); + assert_eq!(kinds(&mut s).0, "none"); + assert_ne!(centre(&s), red, "the fill is off"); + // A radial gradient: the Fill becomes a Gradient Fill (switched on) that keeps its opacity. + s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#ff0000"})).unwrap(); + assert_eq!(centre(&s), red); + let opacity = |s: &Session, m: &str| { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap(); + l.props.group(&format!("contents/group/contents/{m}")).unwrap().get("opacity").unwrap().value.as_f64() + }; + s.execute("prop.set", json!({"layer": sh, "path": "contents/group/contents/fill/opacity", "value": 40})).unwrap(); + s.execute("shape.fillStroke", json!({"fillType": "radial"})).unwrap(); + assert_eq!(items(&s), [("rect".into(), true), ("gfill".into(), true)]); + assert_eq!((kinds(&mut s).0.as_str(), opacity(&s, "gfill")), ("radial", 40.0)); + s.execute("shape.fillStroke", json!({"fillType": "linear"})).unwrap(); + assert_eq!(kinds(&mut s).0, "linear"); + s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#00ff00"})).unwrap(); + assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), true)]); + assert_eq!(opacity(&s, "fill"), 40.0); + // A Stroke kind adds a stroke (before the fill); None switches it off again; undo is per step. + s.execute("shape.fillStroke", json!({"strokeType": "linear"})).unwrap(); + assert_eq!(items(&s), [("rect".into(), true), ("gstroke".into(), true), ("fill".into(), true)]); + s.execute("shape.fillStroke", json!({"strokeType": "none"})).unwrap(); + assert_eq!(kinds(&mut s), ("solid".into(), "none".into())); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(kinds(&mut s), ("solid".into(), "linear".into())); + assert!(s.execute("shape.fillStroke", json!({"fillType": "plaid"})).is_err()); +} + /// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the /// viewer drags the whole mask (#203); a shape's Path item works the same way. #[test] diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1260c9c9..ca4cea15 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -133,13 +133,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect() }) .unwrap_or_default(); - let layer_paint = shape_layers - .first() - .and_then(|id| app.session.active_comp()?.layer(LayerId(*id))) - .map(|l| effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time()))); - if let Some((fill, stroke, width)) = layer_paint { + let layer_paint = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))).map(|l| { + let lt = l.layer_time(app.session.time()); + (effectcraft_engine::commands::shape_stroke::first_paint(l, lt), effectcraft_engine::commands::shape_stroke::first_paint_kinds(l, lt)) + }); + if let Some(((fill, stroke, width), (fill_kind, stroke_kind))) = layer_paint { let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32]; let tool = &mut app.session.state.shape_tool; + tool.fill.kind = fill_kind; + tool.stroke.kind = stroke_kind; if let Some(c) = fill { tool.fill.color = rgb(c); } @@ -363,8 +365,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa if ui.input(|i| i.modifiers.alt) { let at = PaintKind::ALL.iter().position(|k| *k == tp.kind).unwrap_or(0); let next = PaintKind::ALL.get((at + 1) % PaintKind::ALL.len()).copied().unwrap_or_default(); - let k = format!("{key}Type"); - tool_options(app, json!({k: next.id()})); + paint_kind(app, shape_layers, key, next); } else if tp.kind == PaintKind::Solid { widgets::open_popup(ui, picker_id(key)); } else { @@ -376,7 +377,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa tool_options(app, json!({key: c})); paint(app, key, json!(c)); } - paint_options_popup(app, ui, key, &options, wr.left_bottom()); + paint_options_popup(app, ui, shape_layers, key, &options, wr.left_bottom()); x = sr.max.x + 12.0; } let width = app.session.state.shape_tool.stroke_width; @@ -389,9 +390,28 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa r.max.x + 14.0 } +/// A Fill or Stroke kind: for new shapes, and (as in After Effects) for the selected shape +/// layers' fills or strokes, which None switches off (#732). +fn paint_kind(app: &mut EffectcraftApp, shape_layers: &[u64], key: &str, kind: PaintKind) { + let k_type = format!("{key}Type"); + tool_options(app, json!({&k_type: kind.id()})); + if shape_layers.is_empty() { + return; + } + let mut v = json!({"layers": shape_layers, k_type: kind.id()}); + // A Solid Color takes the swatch's colour. + let color = if key == "fill" { app.session.state.shape_tool.fill.color } else { app.session.state.shape_tool.stroke.color }; + if let (PaintKind::Solid, Some(o)) = (kind, v.as_object_mut()) { + o.insert(key.to_string(), json!(color)); + } + if let Err(e) = app.session.execute("shape.fillStroke", v) { + app.ui.status = e.to_string(); + } +} + /// Fill Options / Stroke Options: None, Solid Color, Linear Gradient or Radial Gradient, and the /// blend mode and opacity new shapes get (After Effects' dialogs, applied as they change). -fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, title: &str, pos: egui::Pos2) { +fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers: &[u64], key: &str, title: &str, pos: egui::Pos2) { let id = egui::Id::new(("tool-paint-options", key)); if !widgets::popup_is_open(ui, id) { return; @@ -400,6 +420,7 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t let tp = if key == "fill" { &app.session.state.shape_tool.fill } else { &app.session.state.shape_tool.stroke }.clone(); let blend_pop = id.with("blend"); let mut set = None; + let mut kind = None; let mut blend_rect = Rect::NOTHING; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { @@ -416,8 +437,7 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t paint_swatch(&p, r.shrink(3.0), &ToolPaint { kind: k, ..tp.clone() }, resp.hovered(), &t); app.auto.add(&format!("header.{key}Options.{}", k.id()), r, k.label()); if resp.clicked() { - let k_type = format!("{key}Type"); - set = Some(json!({k_type: k.id()})); + kind = Some(k); } } p.text(row(33.0) + vec2(134.0, 0.0), Align2::LEFT_CENTER, tp.kind.label(), Tokens::ui(11.5), t.text_dim); @@ -451,6 +471,9 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t if let Some(v) = set { tool_options(app, v); } + if let Some(k) = kind { + paint_kind(app, shape_layers, key, k); + } } /// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash. diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index bdb4189d..b7c732ce 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -601,6 +601,32 @@ fn fill_options_paint_the_next_shape() { assert_eq!(gfill.get("opacity").unwrap().value.as_f64(), 40.0); } +/// With a shape layer selected, Fill Options' None switches its fill off and Solid Color turns +/// it back on, and the Fill swatch shows the layer's kind (#732). +#[test] +fn fill_options_change_the_selected_shape_layer() { + let mut h = harness(); + let l = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [100, 100], "position": [320, 180]})).unwrap()["layer"] + .as_u64() + .unwrap(); + h.run_steps(2); + let fill_on = |h: &Harness<'_, EffectcraftApp>| { + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + layer.props.group("contents/group/contents/fill").unwrap().enabled + }; + assert!(fill_on(&h)); + for (kind, on) in [("none", false), ("solid", true)] { + let at = rect(&h, "header.fillOptions").center(); + click(&mut h, at); + let at = rect(&h, &format!("header.fillOptions.{kind}")).center(); + click(&mut h, at); + h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(3); + assert_eq!(fill_on(&h), on, "{kind}"); + assert_eq!(h.state().session.state.shape_tool.fill.kind.id(), kind); + } +} + #[test] fn shape_pen_draws_a_closed_shape_layer() { let mut h = harness();