From 34aef98725d5db8bcc04d25d423a7befc80a564b Mon Sep 17 00:00:00 2001 From: Egon 5000 <210198019+elZanco@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:17:38 +0200 Subject: [PATCH 1/3] UI: double-clicking a creation tool in the Tools bar makes an instance (#1072) A double-click on a creation tool in the Tools bar makes what it draws, as in After Effects: a text layer from the Type tools; from the shape tools a shape that fills the comp - into the selected shape layer's Contents, as a mask on the selected layer (or when Tool Creates Mask is on), or as a new shape layer when neither applies. The shape geometry defaults now match AE: the Rounded Rectangle tool's Roundness is a fixed 20 instead of growing with the drawn size, and the Polygon tool draws five points. --- crates/engine/src/cmd/shape_tool.rs | 13 +- crates/engine/src/tests/viewer.rs | 2 +- crates/ui-egui/src/header.rs | 275 +++++++++++++++++++++++++++ crates/ui-egui/tests/it/ui_viewer.rs | 5 +- 4 files changed, 286 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index e1f3607b1..3323d61d6 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -180,16 +180,17 @@ fn rgba(c: [f32; 3]) -> [f64; 4] { [c[0] as f64, c[1] as f64, c[2] as f64, 1.0] } -/// The Rounded Rectangle tool's corner roundness for `size`. -fn roundness(size: [f64; 2]) -> f64 { - size[0].min(size[1]) * 0.15 +/// The Rounded Rectangle tool's corner roundness: a fixed default, as in After Effects +/// (it does not grow with the drawn size). +fn roundness() -> f64 { + 20.0 } /// The Polygon and Star tools' Polystar for `size`: (star, points, outer radius, inner radius). fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> { match kind { "star" => Some((true, 5.0, size[0] / 2.0, size[0] / 4.0)), - "polygon" => Some((false, 6.0, size[0] / 2.0, 0.0)), + "polygon" => Some((false, 5.0, size[0] / 2.0, 0.0)), _ => None, } } @@ -199,7 +200,7 @@ fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> { pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &ShapeTool) -> Option { let (path, gname) = match kind { "rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"), - "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "Rectangle 1"), + "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness()), "Rectangle 1"), "ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"), k => { let (star, points, outer, inner) = polystar_of(k, size)?; @@ -218,7 +219,7 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option return Some(ShapePath::rect(centre, size[0], size[1])), "ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])), - "rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)), + "rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness()), k => { let (star, points, outer, inner) = polystar_of(k, size)?; effectcraft_path::polystar(effectcraft_path::Polystar { diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index 5ed0848b9..1ff12237e 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -419,7 +419,7 @@ fn shape_tool_options_paint_new_shapes() { fn masks_of_every_shape_tool_kind() { let mut s = comp(); let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); - for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 6), ("star", 10)] { + for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 5), ("star", 10)] { let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap(); let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); assert!(path.closed, "{kind}"); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 3eee89453..1149eb94a 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -12,6 +12,7 @@ use crate::widgets; use crate::{Dialog, EffectcraftApp}; use effectcraft_color::BlendMode; use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; +use effectcraft_engine::geom::vec2 as gv2; use effectcraft_engine::project::{LayerId, LayerSource, PropGroup}; use effectcraft_engine::viewer as vw; @@ -109,6 +110,11 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) { app.ui.status = e; + } else if resp.double_clicked() + && let Some((id, params)) = create_on_double_click(app, cur) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params) + { + app.ui.status = e; } if slot.len() > 1 { slot_flyout(app, ui, si, slot, &resp, held_id); @@ -374,6 +380,51 @@ fn workspace_tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter } } +/// Double-clicking a creation tool in the Tools bar makes an instance of what it draws, as in +/// After Effects (#1072): a text layer from the Type tools; from the shape tools, a shape that +/// fills the comp — into the selected shape layer's Contents, as a mask on the selected layer +/// (any non-shape layer, or Tool Creates Mask), or as a new shape layer when neither applies. +fn create_on_double_click(app: &EffectcraftApp, tool: Tool) -> Option<(&'static str, serde_json::Value)> { + match tool { + // `invoke` turns empty params into an insertion point in the comp centre that starts + // editing (#866), which is what double-clicking the Type tool should do. + Tool::Type | Tool::TypeVertical => Some(("layer.newText", json!({}))), + Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => { + let kind = match tool { + Tool::Rectangle => "rect", + Tool::RoundedRect => "rounded", + Tool::Ellipse => "ellipse", + Tool::Polygon => "polygon", + _ => "star", + }; + // Without a comp the command still runs and reports its error on the status line. + let Some(comp) = app.session.active_comp() else { + return Some(("shape.newShape", json!({"kind": kind}))); + }; + let (cw, ch) = (comp.width as f64, comp.height as f64); + // Rectangles and ellipses fill the comp; a Polystar's Outer Radius is half the comp + // height (Inner Radius: a quarter of it), so it gets a comp-height square. + let (w, h) = if matches!(tool, Tool::Polygon | Tool::Star) { (ch, ch) } else { (cw, ch) }; + // A mask on the selected layer: any non-shape layer (the tool switches to Mask by + // itself), or a shape layer while Tool Creates Mask is on — as dragging does. + let sel = app.session.state.selected_layers.first().copied().and_then(|id| comp.layer(id)); + if let Some(l) = sel.filter(|l| l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape))) { + // The box in layer space, so a moved or scaled layer still gets a comp-filling mask. + let ctx = effectcraft_engine::render::EvalCtx::new(&app.session.project, app.session.active_comp_id()?, comp, app.session.time()); + let inv = ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_engine::geom::Mat3::IDENTITY); + let p0 = inv.apply(gv2(cw / 2.0 - w / 2.0, ch / 2.0 - h / 2.0)); + let p1 = inv.apply(gv2(cw / 2.0 + w / 2.0, ch / 2.0 + h / 2.0)); + let rect = [p0.x.min(p1.x), p0.y.min(p1.y), (p1.x - p0.x).abs(), (p1.y - p0.y).abs()]; + return Some(("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect}))); + } + // Into the selected shape layer's Contents, else a new shape layer (the engine's own + // target), painted with the Tools bar's Fill and Stroke. + Some(("shape.newShape", json!({"kind": kind, "size": [w, h]}))) + } + _ => None, + } +} + /// The Snapping options menu beside the checkbox: After Effects' Snap Edges Extended and Snap to /// Features in Collapsed Compositions and Text Layers (`view.snappingOptions`). fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) { @@ -834,4 +885,228 @@ mod tests { frame(&mut app, &ctx, 2.1, vec![]); assert!(!app.auto.elements.iter().any(|w| w.id == "tools.select.TypeVertical")); } + + /// Double-clicking a creation tool in the Tools bar makes an instance of what it draws + /// (#1072): with nothing selected a shape layer whose shape fills the comp, into the selected + /// shape layer's Contents, a mask on a non-shape layer, a text layer from the Type tools. + /// Tools that create nothing stay quiet. + #[test] + fn double_clicking_creation_tools_makes_instances() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Both click pairs in one frame, as egui counts a double-click. + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // Nothing selected: a shape layer with a Rectangle Path 1 the size of the comp. + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "the double-click made a layer"); + let l = &comp.layers[0]; + assert!(matches!(l.source, LayerSource::Shape), "a shape layer"); + let size = l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").expect("Rectangle Path 1"); + assert_eq!(size.value.as_vec2(), [640.0, 360.0], "the rectangle fills the comp"); + } + // The shape layer is selected: the Ellipse goes on top of its Contents, no new layer. + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "into the selected shape layer's Contents"); + let l = &comp.layers[0]; + assert!(l.props.prop("contents/Ellipse 1/contents/Ellipse Path 1/size").is_some(), "the new ellipse is on top"); + assert!(l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").is_some(), "the rectangle still stands"); + } + // The Type tool: a text layer above the selection, editing started (an insertion point, #866). + double_click(&mut app, &ctx, 3.0, "tools.Type"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2); + assert!(matches!(comp.layers[0].source, LayerSource::Text)); + assert!(app.session.state.text_edit.is_some(), "the text layer is being edited"); + } + // A non-shape layer is selected, so the Rectangle becomes a comp-filling mask on it. + app.ui.slot_tools[8] = Tool::Rectangle; + frame(&mut app, &ctx, 3.05, vec![]); + double_click(&mut app, &ctx, 4.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2, "a mask, not a layer"); + let p = comp.layers[0].props.prop("masks/Mask 1/path").expect("Mask 1"); + let sh = p.value.as_path().expect("a mask path"); + let b = effectcraft_path::bounds(&[effectcraft_path::to_kurbo(sh)]).expect("bounds"); + // The text layer is anchored top-left and centred in the comp: layer = comp - centre. + for (got, want) in [(b.x0, -320.0), (b.y0, -180.0), (b.x1, 320.0), (b.y1, 180.0)] { + assert!((got - want).abs() < 1.0, "the mask fills the comp: {got} vs {want}"); + } + } + // A tool that creates nothing: no layer, no error. + double_click(&mut app, &ctx, 5.0, "tools.Hand"); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 2, "the Hand tool creates nothing"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + // Without a comp the command fails with the status line, never a panic. + let mut bare = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &bare.tokens, crate::i18n::language(&bare)); + frame(&mut bare, &ctx, 0.0, vec![]); + frame(&mut bare, &ctx, 0.1, vec![]); + double_click(&mut bare, &ctx, 1.0, "tools.Rectangle"); + assert!(!bare.ui.status.is_empty(), "no comp: a status message, not a crash"); + } + + /// Tool Creates Mask on (#1072): a double-click on a shape tool masks the selected shape + /// layer instead of adding to its Contents. + #[test] + fn double_click_masks_a_shape_layer_while_tool_creates_mask() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // A shape layer, selected by the double-click that made it. + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1); + app.session.state.shape_tool.creates_mask = true; + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "a mask, not a second shape layer"); + let l = &comp.layers[0]; + assert!(l.props.prop("masks/Mask 1/path").is_some(), "an ellipse mask"); + assert!(l.props.prop("contents/Ellipse 1").is_none(), "nothing added to the Contents"); + let p = l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").expect("the rectangle stands"); + assert_eq!(p.value.as_vec2(), [640.0, 360.0]); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// The shape tools' geometry defaults (#1072): the Rounded Rectangle tool's Roundness is a + /// fixed 20, the Polygon tool a 5-point polygon, the Star tool a 5-point star whose Inner + /// Radius is a quarter of its Outer Radius. + #[test] + fn double_click_shape_tool_geometry_defaults() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // Each shape on its own layer: deselect so the next double-click makes a new one. + let deselected = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64| { + app.session.execute("edit.deselectAll", json!({})).unwrap(); + frame(app, ctx, t, vec![]); + }; + // The Rounded Rectangle: Roundness 20, filling the comp. + app.ui.slot_tools[8] = Tool::RoundedRect; + frame(&mut app, &ctx, 1.0, vec![]); + double_click(&mut app, &ctx, 1.1, "tools.RoundedRect"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Rectangle 1/contents/Rectangle Path 1").expect("Rectangle Path 1"); + assert_eq!(g.prop("roundness").expect("roundness").value.as_f64(), 20.0, "a fixed roundness"); + assert_eq!(g.prop("size").expect("size").value.as_vec2(), [640.0, 360.0]); + } + // The Star: 5 points, Outer Radius half the comp height, Inner Radius a quarter. + deselected(&mut app, &ctx, 2.0); + app.ui.slot_tools[8] = Tool::Star; + frame(&mut app, &ctx, 2.05, vec![]); + double_click(&mut app, &ctx, 2.1, "tools.Star"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Polystar 1/contents/Polystar Path 1").expect("Polystar Path 1"); + assert_eq!(g.prop("type").expect("type").value.as_enum(), 0, "Star"); + assert_eq!(g.prop("points").expect("points").value.as_f64(), 5.0, "5 points"); + assert_eq!(g.prop("outerRadius").expect("outer").value.as_f64(), 180.0, "half the comp height"); + assert_eq!(g.prop("innerRadius").expect("inner").value.as_f64(), 90.0, "a quarter of the comp height"); + } + // The Polygon: 5 points, Outer Radius half the comp height, no inner radius. + deselected(&mut app, &ctx, 3.0); + app.ui.slot_tools[8] = Tool::Polygon; + frame(&mut app, &ctx, 3.05, vec![]); + double_click(&mut app, &ctx, 3.1, "tools.Polygon"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Polystar 1/contents/Polystar Path 1").expect("Polystar Path 1"); + assert_eq!(g.prop("type").expect("type").value.as_enum(), 1, "Polygon"); + assert_eq!(g.prop("points").expect("points").value.as_f64(), 5.0, "5 points"); + assert_eq!(g.prop("outerRadius").expect("outer").value.as_f64(), 180.0, "half the comp height"); + assert!(g.prop("innerRadius").is_none(), "a polygon has no inner radius"); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// A double-clicked shape is painted with the Tools bar's Fill and Stroke (#1072): the + /// colours and stroke width the shape tool is set to — and no outline at all when the tool + /// has its Stroke off. + #[test] + fn double_click_paints_with_the_shape_tools_fill_and_stroke() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // The tool's own Fill and Stroke go onto the created shape. + app.session.state.shape_tool.fill.color = [1.0, 0.0, 0.0]; + app.session.state.shape_tool.stroke.color = [0.0, 1.0, 0.0]; + app.session.state.shape_tool.stroke_width = 5.0; + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Rectangle 1/contents").expect("the contents"); + let fill = g.sub("fill").expect("the fill"); + assert_eq!(fill.get("color").expect("color").value.as_color(), [1.0, 0.0, 0.0, 1.0], "the tool's Fill"); + let stroke = g.sub("stroke").expect("the stroke"); + assert_eq!(stroke.get("color").expect("color").value.as_color(), [0.0, 1.0, 0.0, 1.0], "the tool's Stroke"); + assert_eq!(stroke.get("width").expect("width").value.as_f64(), 5.0, "the tool's Stroke Width"); + } + // Stroke off ("Keine"): the next shape gets no outline at all, but keeps the Fill. + app.session.execute("shape.toolOptions", json!({"stroke": false})).unwrap(); + app.session.execute("edit.deselectAll", json!({})).unwrap(); + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2, "a new shape layer"); + let g = comp.layers[0].props.group("contents/Ellipse 1/contents").expect("the ellipse"); + assert!(g.sub("stroke").is_none(), "no outline when the tool's Stroke is off"); + assert_eq!(g.sub("fill").expect("the fill").get("color").expect("color").value.as_color(), [1.0, 0.0, 0.0, 1.0]); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } } diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index c9024ac58..0f5792192 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -482,8 +482,9 @@ fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] { fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() { let (from, to) = ([140.0, 100.0], [400.0, 300.0]); let centre = [270.0, 200.0]; - // 6 comp px inside the box's top-left corner: inside a rectangle, outside every other kind. - let corner = [146.0, 106.0]; + // 4 comp px inside the box's top-left corner: inside a rectangle, outside every other kind + // (a rounded corner of 20 px arcs past this point). + let corner = [144.0, 104.0]; for (tool, fills_corner) in [(Tool::Rectangle, true), (Tool::RoundedRect, false), (Tool::Ellipse, false), (Tool::Polygon, false), (Tool::Star, false)] { let mut h = harness(); h.state_mut().ui.tool = tool; From 2fb803fa82c28b171ac4a4673762f41853ee6bca Mon Sep 17 00:00:00 2001 From: elZanco Date: Tue, 20 Oct 2026 17:30:03 +0200 Subject: [PATCH 2/3] UI: --- crates/ui-egui/src/header.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1149eb94a..8a21c2fb5 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -964,6 +964,32 @@ mod tests { assert!(!bare.ui.status.is_empty(), "no comp: a status message, not a crash"); } + /// #1765: a double-click on the Ellipse tool with nothing selected makes its own shape layer + /// the size of the comp, as the Rectangle does. + #[test] + fn double_click_ellipse_with_no_selection_makes_a_layer() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.0, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let e = app.auto.elements.iter().find(|w| w.id == "tools.Ellipse").unwrap_or_else(|| panic!("no tools.Ellipse")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(&mut app, &ctx, 1.1, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "the double-click made a layer"); + let l = &comp.layers[0]; + assert!(matches!(l.source, LayerSource::Shape), "a shape layer"); + let size = l.props.prop("contents/Ellipse 1/contents/Ellipse Path 1/size").expect("Ellipse Path 1"); + assert_eq!(size.value.as_vec2(), [640.0, 360.0], "the ellipse fills the comp"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + /// Tool Creates Mask on (#1072): a double-click on a shape tool masks the selected shape /// layer instead of adding to its Contents. #[test] From 7081ba4d7566ea0c3dbed56a572f7aa1e554b43f Mon Sep 17 00:00:00 2001 From: Egon 5000 <210198019+elZanco@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:05:06 +0200 Subject: [PATCH 3/3] UI: map all four box corners for the double-click mask on a rotated layer Two corners give a sliver instead of a comp-filling mask when the layer is rotated by an angle other than a multiple of 90 degrees (CodeRabbit on #1654): map the comp box's four corners into layer space and use their bounds. --- crates/ui-egui/src/header.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 8a21c2fb5..0ad6c3706 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -409,12 +409,19 @@ fn create_on_double_click(app: &EffectcraftApp, tool: Tool) -> Option<(&'static // itself), or a shape layer while Tool Creates Mask is on — as dragging does. let sel = app.session.state.selected_layers.first().copied().and_then(|id| comp.layer(id)); if let Some(l) = sel.filter(|l| l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape))) { - // The box in layer space, so a moved or scaled layer still gets a comp-filling mask. + // The box in layer space, so a moved, scaled or rotated layer still gets a + // comp-filling mask: all four corners are mapped, and their bounds are the + // smallest axis-aligned box around the comp box. let ctx = effectcraft_engine::render::EvalCtx::new(&app.session.project, app.session.active_comp_id()?, comp, app.session.time()); let inv = ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_engine::geom::Mat3::IDENTITY); - let p0 = inv.apply(gv2(cw / 2.0 - w / 2.0, ch / 2.0 - h / 2.0)); - let p1 = inv.apply(gv2(cw / 2.0 + w / 2.0, ch / 2.0 + h / 2.0)); - let rect = [p0.x.min(p1.x), p0.y.min(p1.y), (p1.x - p0.x).abs(), (p1.y - p0.y).abs()]; + let (x0, y0, x1, y1) = (cw / 2.0 - w / 2.0, ch / 2.0 - h / 2.0, cw / 2.0 + w / 2.0, ch / 2.0 + h / 2.0); + let pts = [gv2(x0, y0), gv2(x1, y0), gv2(x1, y1), gv2(x0, y1)].map(|p| inv.apply(p)); + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for p in pts { + lo = [lo[0].min(p.x), lo[1].min(p.y)]; + hi = [hi[0].max(p.x), hi[1].max(p.y)]; + } + let rect = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]]; return Some(("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect}))); } // Into the selected shape layer's Contents, else a new shape layer (the engine's own