From 0d2264dbed4e50a58aa98dfb60c26069eb2c9244 Mon Sep 17 00:00:00 2001 From: Aavirash Date: Fri, 9 Oct 2026 17:47:19 +0200 Subject: [PATCH 1/4] Viewer: the shape tools show the tool's own shape while dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drag preview was a 1 px axis-aligned rectangle for every shape tool, which read as no preview at all against most comps. Draw the tool's own path instead (rectangle, rounded rectangle, ellipse, polygon, star), from the engine's own geometry, filled with the Tools bar's Fill and outlined in its Stroke, with a caption saying where the shape lands — a mask on the selected layer, into the selected shape layer, or a new shape layer. The ghost and the released shape go through one normalisation, so the drag shows exactly what it commits: Alt (Option) draws from the press point and Shift squares the box, both live. The drag also starts at the press point now, not where the 6 px drag threshold was crossed, so the shape is drawn from where the button went down, as in After Effects. --- crates/engine/src/commands/shape_tool.rs | 14 +- crates/ui-egui/src/panels/viewer.rs | 190 +++++++++++++++---- crates/ui-egui/src/panels/viewer_overlays.rs | 4 +- crates/ui-egui/tests/ui_viewer.rs | 158 +++++++++++++++ 4 files changed, 329 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs index d02e3fbe..83ab0f01 100644 --- a/crates/engine/src/commands/shape_tool.rs +++ b/crates/engine/src/commands/shape_tool.rs @@ -205,8 +205,10 @@ pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &Sha Some(build::shape_group(ids, gname, items)) } -/// The mask a shape tool draws: `kind` of `size` centred at `centre` (layer space). -pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option { +/// The path a shape tool draws: `kind` of `size` centred at `centre` (layer space). The geometry +/// of every one of the five kinds, so a caller that only wants to draw it (the viewer's +/// drag ghost) cannot drift from what `shape.newShape` / `layer.addMask` commit. +pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option { let bez = match kind { "rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])), "ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])), @@ -219,12 +221,18 @@ pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option< effectcraft_path::from_kurbo(&bez).into_iter().next() } +/// The layer a shape tool draws into when no `layer` is given: the first selected unlocked shape +/// layer, else none (a new shape layer). +pub fn selected_shape_target(s: &Session, comp: &Comp) -> Option { + s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)) +} + /// The shape layer a shape tool or the Pen draws into: `layer` (which must be a shape layer), /// else the first selected unlocked shape layer, else none (a new shape layer). pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Result> { let target = match p.get("layer") { Some(l) => Some(super::resolve_layer(comp, l).ok_or_else(|| bad(cmd, format!("no layer {l}")))?), - None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)), + None => selected_shape_target(s, comp), }; if target.and_then(|l| comp.layer(l)).is_some_and(|l| !matches!(l.source, LayerSource::Shape)) { return Err(bad(cmd, "the layer is not a shape layer")); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9bc70cb1..c0bd807f 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -4,9 +4,10 @@ //! 3D views (Active Camera / Front / … / Custom View 3), camera tools (orbit, pan, dolly) and //! camera/light wireframes. +use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; -use effectcraft_engine::project::{Comp, Layer, LayerId}; +use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; use effectcraft_engine::time::Tick; @@ -1173,7 +1174,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { anchor_only: mods.alt, }) }), - t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), + // From where the button went down: by the time the pointer has moved far enough to + // count as a drag it has already left the press point, and the shape is drawn there. + t if t.is_shape() => Some(Gesture::Create { tool: t, start: map.to_comp(press) }), t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => { // Off the pins: Alt or a press outside the art drags a marquee that selects pins. (mods.alt || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press }) @@ -1380,10 +1383,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - Gesture::Create { start, .. } => { - let a = map.to_screen(start); - let r = Rect::from_two_pos(a, pos); - painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle); + Gesture::Create { tool, start } => { + if let Some(d) = shape_drag(app, tool, start, cpt, mods) { + draw_shape_ghost(&painter, &map, app, &d, &t); + } } Gesture::Vertices { start, src, applied, inv, layer } => { // The grabbed vertex snaps (to other layers' vertices and features, guides, grid). @@ -1522,7 +1525,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin), Gesture::Create { tool, start } => { let end = map.to_comp(pos); - create_shape(app, tool, start, end, mods.shift); + create_shape(app, tool, start, end, mods); } Gesture::Overlay(ov::Drag::Roi { start, keep }) => { let c = map.to_comp(pos); @@ -1896,38 +1899,159 @@ pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], togg Some(hit) } -fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], square: bool) { - let mut w = (b[0] - a[0]).abs(); - let mut h = (b[1] - a[1]).abs(); - if w < 2.0 && h < 2.0 { - return; +/// Where a shape drag is going to land: a mask on the selected layer, a group inside it, or a new +/// shape layer. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ShapeTarget { + Mask, + IntoLayer, + NewLayer, +} + +impl ShapeTarget { + /// What the drag will make and where, named the way the ghost captions it. + fn label(self, layer_name: &str) -> String { + match self { + ShapeTarget::Mask => format!("Mask on “{layer_name}”"), + ShapeTarget::IntoLayer => format!("Into “{layer_name}”"), + ShapeTarget::NewLayer => "New shape layer".to_string(), + } } - if square { - let m = w.max(h); - w = m; - h = m; - } - let cx = a[0].min(b[0]) + w / 2.0; - let cy = a[1].min(b[1]) + h / 2.0; - let kind = match tool { +} + +/// A shape drag as it stands, after the Tools' normalisations: the path it will make and where it +/// will land. +struct ShapeDrag { + /// The `shape.newShape` / `layer.addMask` kind for the tool. + kind: &'static str, + /// Comp-space centre and size, what the drag will make. + centre: [f64; 2], + size: [f64; 2], + target: ShapeTarget, + /// The selected layer's name and the index its next mask would take, for the ghost's caption + /// and colour. + layer_name: String, + mask_index: usize, +} + +/// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it. +fn shape_kind(tool: Tool) -> &'static str { + match tool { Tool::Rectangle => "rect", Tool::RoundedRect => "rounded", Tool::Ellipse => "ellipse", Tool::Polygon => "polygon", _ => "star", - }; + } +} + +/// What a shape tool drag from `start` to `end` makes, or `None` while it is too small to make +/// anything. Alt (Option) draws from the press point and Shift squares the box; the live ghost and +/// [`create_shape`] both go through this, so the drag always shows what it will commit. +fn shape_drag(app: &EffectcraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option { + let comp = app.session.active_comp()?; + let selected = app.session.state.selected_layers.first().copied(); + let sel = selected.and_then(|id| comp.layer(id)); // A footage, solid or other non-shape layer selected, or a shape layer with Tool Creates - // Mask: a mask on it instead. - let creates_mask = app.session.state.shape_tool.creates_mask; - let sel = app.session.state.selected_layers.first().copied(); - let sel_layer = sel.and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned()); - if let Some(l) = sel_layer - && l.source.is_av() - && (creates_mask || !matches!(l.source, effectcraft_engine::project::LayerSource::Shape)) - { - // Mask in layer space: invert the layer transform. - let comp = app.session.active_comp_arc(); - if let Some(comp) = comp { + // Mask: a mask on that layer. Otherwise the engine's own target: the first selected unlocked + // shape layer, else a new one. + let target = match sel { + Some(l) if l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape)) => (ShapeTarget::Mask, Some(l)), + _ => match effectcraft_engine::commands::shape_tool::selected_shape_target(&app.session, comp).and_then(|id| comp.layer(id)) { + Some(l) => (ShapeTarget::IntoLayer, Some(l)), + None => (ShapeTarget::NewLayer, None), + }, + }; + let (target, layer) = target; + let mask_index = if target == ShapeTarget::Mask { layer.and_then(|l| l.masks()).map_or(0, |m| m.groups().count()) } else { 0 }; + let (mut w, mut h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); + // Alt doubles the box about the press point, which is then its centre. + if mods.alt { + w *= 2.0; + h *= 2.0; + } + if w < 2.0 && h < 2.0 { + return None; + } + if mods.shift { + let m = w.max(h); + w = m; + h = m; + } + Some(ShapeDrag { + kind: shape_kind(tool), + centre: if mods.alt { start } else { [start[0].min(end[0]) + w / 2.0, start[1].min(end[1]) + h / 2.0] }, + size: [w, h], + target, + layer_name: layer.map_or_else(String::new, |l| l.name.clone()), + mask_index, + }) +} + +/// The Tools bar's paint colour as bytes, dimmed by `alpha` so the comp shows through the ghost. +fn paint_rgb(c: [f32; 3], opacity: f64, alpha: f32) -> Color32 { + let f = |k: f32| (k.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(f(c[0]), f(c[1]), f(c[2])).gamma_multiply((opacity.clamp(0.0, 100.0) / 100.0) as f32 * alpha) +} + +/// Draw a shape drag as it will land: the tool's own path, filled with the Tools bar's Fill and +/// outlined in its Stroke (or the mask colour, for a mask), captioned with where it goes. +fn draw_shape_ghost(painter: &egui::Painter, map: &ViewerMap, app: &EffectcraftApp, d: &ShapeDrag, t: &Tokens) { + // The engine's own geometry for the kind, so the ghost is the shape and not a stand-in for it. + let Some(sp) = effectcraft_engine::commands::shape_tool::mask_path(d.kind, d.centre, d.size) else { return }; + let mut pts = ov::flatten(&sp, &Mat3::IDENTITY, map); + if pts.first() == pts.last() { + pts.pop(); + } + if pts.len() < 3 { + return; + } + let tool = &app.session.state.shape_tool; + let (fill, line) = match d.target { + ShapeTarget::Mask => { + let [r, g, b] = effectcraft_engine::prefs::mask_color(app.session.prefs.appearance.cycle_mask_colors, d.mask_index); + (Color32::from_rgb(r, g, b).gamma_multiply(0.25), Color32::from_rgb(r, g, b)) + } + _ => { + let stroke_on = tool.stroke.kind != PaintKind::None && tool.stroke_width > 0.0; + ( + if tool.fill.kind == PaintKind::None { Color32::TRANSPARENT } else { paint_rgb(tool.fill.color, tool.fill.opacity, 0.45) }, + if stroke_on { paint_rgb(tool.stroke.color, tool.stroke.opacity, 1.0) } else { t.accent }, + ) + } + }; + let Some(bounds) = bounds_of(&pts) else { return }; + // A screen-width outline reads at every zoom, where a comp-pixel hairline does not. + painter.add(egui::Shape::Path(egui::epaint::PathShape { points: pts, closed: true, fill, stroke: Stroke::new(1.5, line).into() })); + // Caption it with where the shape lands: a mask, a group in the selected shape layer or a + // new layer. + let font = Tokens::ui(11.0); + let galley = painter.layout_no_wrap(d.target.label(&d.layer_name), font, line); + let caption = Rect::from_min_size(pos2(bounds.left(), bounds.top() - 5.0 - galley.size().y), galley.size()); + painter.rect_filled(caption.expand(3.0), 2.0, Color32::from_black_alpha(150)); + painter.galley(caption.min, galley, line); +} + +/// The bounds of points on screen, or `None` for an empty set. +fn bounds_of(pts: &[Pos2]) -> Option { + let first = *pts.first()?; + let mut r = Rect::from_min_max(first, first); + for p in pts.iter().skip(1) { + r.extend_with(*p); + } + Some(r) +} + +fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], mods: egui::Modifiers) { + let Some(d) = shape_drag(app, tool, a, b, mods) else { return }; + let (kind, (w, h), (cx, cy)) = (d.kind, (d.size[0], d.size[1]), (d.centre[0], d.centre[1])); + // A mask on the selected layer, as the ghost showed. + if d.target == ShapeTarget::Mask { + // In layer space: invert the layer transform. + let sel_layer = app.session.state.selected_layers.first().copied().and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned()); + if let Some(l) = sel_layer + && let Some(comp) = app.session.active_comp_arc() + { let ectx = EvalCtx { project: &app.session.project, comp_id: app.session.active_comp_id().unwrap_or_default(), @@ -1943,8 +2067,8 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], if let Err(e) = app.session.execute("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect})) { app.ui.status = e.to_string(); } - return; } + return; } // Into the selected shape layer's Contents as a new group (as in After Effects), else a new // shape layer; painted with the Tools bar's Fill and Stroke. diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 51a7d1e5..0038f192 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -98,7 +98,9 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { +/// The path on screen: `sp` through `m` and the viewer map, flattened, with the closing point +/// repeated for a closed path. +pub(crate) fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec { let k = effectcraft_engine::render::kurbo_path(sp); let mut pts: Vec = vec![]; kurbo::flatten(k.iter(), 0.5, |el| match el { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index cfb1db37..c70ec07c 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -368,6 +368,164 @@ fn tool_creates_mask_draws_a_mask_on_the_selected_shape_layer() { assert!(!h.state().session.state.shape_tool.creates_mask); } +// ---- the shape tools' drag ghost + +/// Run frames until the viewer's background frame has landed, so the comp is painted (and not +/// still black) before the drag begins: the ghost is then the only thing that changes. +fn settle_viewer(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(4); +} + +/// Press at `from`, drag to `to` with `mods` held and return the frame drawn with the button still +/// down — what the user sees mid-drag, before anything is committed. egui only takes modifiers +/// from [`egui::Event::ModifiersChanged`], so they are set before the press and stay set. +fn mid_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, mods: egui::Modifiers) -> image::RgbaImage { + h.input_mut().events.push(Event::ModifiersChanged(mods)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: mods }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.render().expect("the mid-drag frame") +} + +/// Release the button where the drag left it. +fn release(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, mods: egui::Modifiers) { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: mods }); + h.run_steps(2); +} + +/// The pixel at a comp point of a rendered frame. +fn px_at(img: &image::RgbaImage, h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> [u8; 3] { + let s = screen(h, p); + let p = img.get_pixel(s.x.round() as u32, s.y.round() as u32).0; + [p[0], p[1], p[2]] +} + +/// How different two pixels are, summed over the channels. +fn delta(a: [u8; 3], b: [u8; 3]) -> u32 { + (0..3).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs()).sum() +} + +/// The Tools-bar Fill as bytes, the colour a filled ghost carries. +fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] { + let c = h.state().session.state.shape_tool.fill.color; + std::array::from_fn(|i| (c[i].clamp(0.0, 1.0) * 255.0).round() as u8) +} + +/// The shape tools show the shape they will draw under the cursor while the drag is still going: +/// filled with the Tools bar's Fill, so it reads at any zoom where the 1 px outline it replaces was +/// invisible, and in the tool's own form — only the Rectangle fills the corner of the box. +#[test] +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]; + 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; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from), screen(&h, to)); + let before = h.render().expect("the frame before the drag"); + let mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); + let fill = fill_rgb(&h); + + // Drawn while the button is down, and painted with the Fill rather than left to a hairline. + let was = px_at(&before, &h, centre); + let now = px_at(&mid, &h, centre); + assert!(delta(was, now) > 30, "{tool:?}: no ghost drawn mid-drag ({was:?} → {now:?})"); + assert!(delta(now, fill) < delta(was, fill), "{tool:?}: the ghost is not the Fill colour ({now:?} vs {was:?})"); + + // The tool's own form: only the Rectangle reaches the corner of its box. + let was_c = px_at(&before, &h, corner); + let now_c = px_at(&mid, &h, corner); + assert_eq!(delta(was_c, now_c) > 30, fills_corner, "{tool:?}: the ghost is not drawn in its own shape"); + + release(&mut h, b, egui::Modifiers::default()); + } +} + +/// The ghost and the shape the release commits share one normalisation, so nothing it shows is a +/// lie: Shift squares the box and Alt (Option) draws it from the press point, both live. +#[test] +fn shape_drag_ghost_normalises_the_same_way_as_the_release() { + let from = [140.0f64, 100.0]; + let to = [400.0f64, 260.0]; + // (mods, centre, size): Shift squares the box from its top-left, Alt centres it on the press. + for (mods, centre, size) in [ + (egui::Modifiers::default(), [270.0, 180.0], [260.0, 160.0]), + (egui::Modifiers::SHIFT, [270.0, 230.0], [260.0, 260.0]), + (egui::Modifiers::ALT, [140.0, 100.0], [520.0, 320.0]), + (egui::Modifiers { alt: true, shift: true, ..Default::default() }, [140.0, 100.0], [520.0, 520.0]), + ] { + let mut h = harness(); + h.state_mut().ui.tool = Tool::Ellipse; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from.map(|v| v as f32)), screen(&h, to.map(|v| v as f32))); + let before = h.render().expect("the frame before the drag"); + // Measured with the button down, so this is the ghost and not the committed shape. + let mid = mid_drag(&mut h, a, b, mods); + let on = [centre[0] as f32 + size[0] as f32 / 4.0, centre[1] as f32 + size[1] as f32 / 4.0]; + assert!(delta(px_at(&before, &h, on), px_at(&mid, &h, on)) > 30, "{mods:?}: the ghost is not where it will land"); + release(&mut h, b, mods); + + // And the shape it commits is that box: a new shape layer carries the position, its + // group sits at its own centre's origin. + let comp = h.state().session.active_comp().unwrap().clone(); + let layer = comp.layers.first().expect("a new shape layer").clone(); + assert!(matches!(layer.source, effectcraft_engine::project::LayerSource::Shape), "{mods:?}: no shape layer"); + let at = layer.transform().unwrap().get("position").unwrap().value.components(); + assert!((at[0] - centre[0]).abs() < 1.0 && (at[1] - centre[1]).abs() < 1.0, "{mods:?}: at {at:?}, not {centre:?}"); + let group = layer.props.sub("contents").unwrap().groups().next().unwrap().clone(); + let path = group.sub("contents").unwrap().sub("ellipse").unwrap(); + let s = path.get("size").unwrap().value.components(); + assert!((s[0] - size[0]).abs() < 1.0 && (s[1] - size[1]).abs() < 1.0, "{mods:?}: sized {s:?}, not {size:?}"); + } +} + +/// The ghost says where the shape lands: a mask on the selected layer is drawn in the mask colour +/// and not painted with the Fill, where a shape drawn into the selected shape layer is. +#[test] +fn shape_drag_ghost_shows_where_the_shape_will_land() { + let (from, to) = ([140.0, 100.0], [400.0, 300.0]); + let centre = [270.0, 200.0]; + + // Nothing selected: the shape becomes a new layer, painted with the Fill. + let mut h = harness(); + h.state_mut().ui.tool = Tool::Ellipse; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from), screen(&h, to)); + let shape_mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); + let shape_px = px_at(&shape_mid, &h, centre); + let fill = fill_rgb(&h); + assert!(delta(shape_px, fill) < delta(shape_px, [0xff, 0xc0, 0x00]), "a shape ghost is painted with the Fill, not the mask colour"); + release(&mut h, b, egui::Modifiers::default()); + + // A solid selected: the same drag is a mask on it, in the mask colour. + let mut h = harness(); + let plate = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id; + h.state_mut().session.execute("layer.select", json!({"layers": [plate.0]})).unwrap(); + h.state_mut().ui.tool = Tool::Ellipse; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from), screen(&h, to)); + let mask_mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); + let mask_px = px_at(&mask_mid, &h, centre); + assert!(delta(mask_px, shape_px) > 20, "a mask ghost must not look like the shape ghost ({mask_px:?} vs {shape_px:?})"); + release(&mut h, b, egui::Modifiers::default()); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!(comp.layer(plate).unwrap().props.sub("masks").unwrap().groups().count(), 1, "the drag drew a mask"); +} + /// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the /// next shape drawn (#227). #[test] From a2554c404c606cddb83509b8a50f5eeb61c307aa Mon Sep 17 00:00:00 2001 From: Aavirash Date: Fri, 9 Oct 2026 17:47:23 +0200 Subject: [PATCH 2/4] Tests: the CLI render-out test compares canonical temp paths macOS's /var is a symlink to /private/var: the CLI resolves its working directory through current_dir(), which is canonical, so the recorded output path was /private/var/... while the test expected /var/.... Canonicalise the test's temp directory so the comparison holds on macOS. --- apps/effectcraft-cli/tests/cli.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 723016a8..d67e768c 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -28,7 +28,9 @@ fn ok_json(args: &[&str]) -> Value { fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); - d.join(name) + // macOS's `/var` is a symlink to `/private/var`: a child process resolves its working + // directory through `current_dir()`, which is canonical, so compare canonical paths. + std::fs::canonicalize(&d).unwrap_or(d).join(name) } #[test] From 603337a0618ce567683314d09c3bd43865ed06fc Mon Sep 17 00:00:00 2001 From: Aavirash Date: Fri, 9 Oct 2026 18:05:38 +0200 Subject: [PATCH 3/4] Viewer: a layer drag moves the picture while the button is down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pointer move replaces the comp's content identity, so the frame on screen takes a new cache key at every step while its render is still in flight; the viewer only ever showed an exact key, so the picture froze on the last completed frame until the drag ended. While a drag or a property scrub is running, show the newest frame of the same comp, frame, scale, view and render options that is ready — a render behind, as After Effects does — and go back to exact frames when it stops. --- crates/ui-egui/src/frames.rs | 19 +++++++++++++++ crates/ui-egui/src/panels/viewer.rs | 16 +++++++++---- crates/ui-egui/tests/ui_viewer.rs | 37 +++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index e1784090..bfdbde04 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -541,6 +541,25 @@ impl Frames { Some(img) } + /// The newest cached frame of `base`'s viewer series — its comp, frame, scale, view and render + /// options, whatever the comp content or revision: the last completed render. A drag (or a + /// property scrub) gives the comp a new content identity with every pointer move, so the + /// viewer falls back to this while the exact key's render is still in flight, and the picture + /// follows the pointer instead of freezing until it stops. `None` if the series has nothing + /// cached. + pub fn get_newest(&self, base: &FrameKey) -> Option<(FrameKey, FrameImage)> { + let mut c = self.cache.lock().ok()?; + let newest = c + .map + .keys() + .filter(|k| k.comp == base.comp && k.frame == base.frame && k.scale == base.scale && k.view == base.view && k.opts == base.opts) + .max_by_key(|k| k.revision) + .copied()?; + let img = c.map.get(&newest).cloned()?; + c.touch(&newest); + Some((newest, img)) + } + /// The [`comp_content`] identity of `comp` in `project` at `revision` (taken once per /// revision). The project is kept while frames of that identity are cached or rendering, so /// the comp addresses it hashes can't be reused by other comps meanwhile. diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index c0bd807f..3fe377ed 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -668,16 +668,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let frame = comp.frame_rate.frame_at(time); let key = app.frame_key(cid, frame, scale); app.request_frame_urgent(cid, frame, scale); - if let Some(img) = app.frames.get(&key) { - let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key); + // The frame on screen. While a drag or a property scrub is rendering, the exact key changes + // with every edit and its render is still in flight; showing the newest frame that is ready + // keeps the picture following the pointer (a render behind), where waiting for the exact key + // would freeze it until the pointer stops. + let interacting = app.ui.viewer.interacting || app.ui.viewer.property_interacting; + let shown = app.frames.get(&key).map(|img| (key, img)).or_else(|| interacting.then(|| app.frames.get_newest(&key)).flatten()); + if let Some((k, img)) = shown { + let stale = app.viewer_shown.as_ref().is_none_or(|(_, s)| *s != k); // A CPU frame goes up again when the magnification calls for another factor. let minify = minify_factor(app.session.prefs.viewer_zoom_smooth(), scale, (zoom * ppp) as f64); let refit = !app.viewer_on_gpu() && app.viewer_tex.as_ref().is_some_and(|(_, _, m)| *m != minify); if stale || refit { - show_frame(app, &ctx, key, img, minify); - } else if let Some((_, k)) = app.viewer_shown.as_mut() { + show_frame(app, &ctx, k, img, minify); + } else if let Some((_, s)) = app.viewer_shown.as_mut() { // The same frame at a later revision (an edit elsewhere, an undo): it shows that one. - k.revision = key.revision; + s.revision = k.revision; } } if app.ui.viewer.transparency_grid { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index c70ec07c..54618dc3 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -526,6 +526,43 @@ fn shape_drag_ghost_shows_where_the_shape_will_land() { assert_eq!(comp.layer(plate).unwrap().props.sub("masks").unwrap().groups().count(), 1, "the drag drew a mask"); } +/// Dragging a layer moves its picture while the button is still down. Every pointer move replaces +/// the comp's content identity, so the exact frame on screen is re-rendered at every step: waiting +/// for it would freeze the viewer on the last completed frame for the whole drag. +#[test] +fn a_layer_drag_moves_the_picture_while_the_button_is_down() { + let mut h = harness(); + let box_ = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id; + h.state_mut().session.execute("layer.select", json!({"layers": [box_.0]})).unwrap(); + settle_viewer(&mut h); + let from = screen(&h, [320.0, 180.0]); + let to = screen(&h, [520.0, 180.0]); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + // Where the layer is not (yet): the plate behind it. + let plate = px_at(&h.render().unwrap(), &h, [440.0, 200.0]); + + // Drag in small steps, still holding the button, and give the renders a chance to land. + let mut moved = false; + for i in 1..=40 { + let p = from + (to - from) * (i as f32 / 40.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + std::thread::sleep(std::time::Duration::from_millis(15)); + // 30 comp px behind the pointer: inside the layer, well away from its outline, its anchor + // icon and where it started. + let at = px_at(&h.render().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]); + if i >= 30 && delta(at, plate) > 30 { + moved = true; + break; + } + } + assert!(moved, "the layer's pixels did not follow the pointer while the button was down"); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + /// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the /// next shape drawn (#227). #[test] From 20f00629813e231c9d798c124377988837e0ffcd Mon Sep 17 00:00:00 2001 From: Aavirash Date: Fri, 9 Oct 2026 18:05:38 +0200 Subject: [PATCH 4/4] Contributors: Aavirash's display name is Asquareportal --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 441b42d7..089fc24a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -36,3 +36,6 @@ display_name = "ShirokoWaWu" [people.sanyamsingh001] display_name = "sanyamsingh001" sync_github_name = false + +[people.Aavirash] +display_name = "Asquareportal"