From 603337a0618ce567683314d09c3bd43865ed06fc Mon Sep 17 00:00:00 2001 From: Aavirash Date: Fri, 9 Oct 2026 18:05:38 +0200 Subject: [PATCH] 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]