From 923ec0fc4a8b1c23335b53ec9fb459d7abe881f6 Mon Sep 17 00:00:00 2001 From: Elias Date: Fri, 9 Oct 2026 21:50:46 +0100 Subject: [PATCH] ui: Graph Editor follows the timeline time zoom; zoom buttons and slider clicks work The Timeline footer's zoom-out / zoom-in mountain icons were painted but not interactive, and the zoom slider only reacted to drags (a click on the track did nothing) and set the zoom without the whole-comp clamp. The icons are now buttons (timeline.zoomOutButton / timeline.zoomInButton, one = / - step), the slider jumps on click, and both go through set_zoom, centred on the CTI. The layer bars and the Graph Editor share one time zoom (TimelineState start / pps, as in After Effects); regression tests drive = / -, the buttons, the slider and the navigator over the control channel in both views and check switching views keeps the zoom. Co-Authored-By: Claude (cherry picked from commit 2ee0fb656ff6bd16eed6857be10495d9f30e3a64) (cherry picked from commit f1997d32d4c51b58467a04944889131fd741a22a) (cherry picked from commit 1c25aeb51d37357b4b37857858db4dbea246b13a) (cherry picked from commit 81d7fcccfc91f76f7b9640cbb7d3f75dcea56e30) --- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 36 ++-- crates/ui-egui/tests/ui_time_zoom_controls.rs | 158 ++++++++++++++++++ 3 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/tests/ui_time_zoom_controls.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c66df7d5..3bcecd77 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -588,7 +588,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.ui.viewer.fast_preview = on; } "timeline.zoomIn" | "timeline.zoomOut" => { - let k = if id == "timeline.zoomIn" { 1.5 } else { 1.0 / 1.5 }; + let k = if id == "timeline.zoomIn" { crate::panels::timeline::ZOOM_STEP } else { 1.0 / crate::panels::timeline::ZOOM_STEP }; crate::panels::timeline::zoom(app, ctx, k); return Ok(Value::Null); } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..f2096cdb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -513,6 +513,9 @@ const GUTTER_W: f32 = 18.0; /// The most a time ruler zooms in (pixels per second). const MAX_PPS: f64 = 4000.0; +/// One `=` / `-` step (and one click on the footer's zoom buttons). +pub(crate) const ZOOM_STEP: f64 = 1.5; + /// Pixels per second that fit `comp` in a time graph `width` points wide. fn fit_pps(width: f32, comp: &Comp) -> f64 { (width - 10.0).max(1.0) as f64 / comp.duration.seconds().max(0.01) @@ -2977,24 +2980,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ); // Zoom slider. let zs = Rect::from_min_max(pos2(graph_x0 + 30.0, footer.center().y - 2.0), pos2((graph_x0 + 230.0).min(rect.max.x - 30.0), footer.center().y + 2.0)); - icons::paint(&p, Rect::from_center_size(pos2(zs.min.x - 14.0, footer.center().y), vec2(14.0, 14.0)), Icon::MountainSmall, t.text_dim); - icons::paint(&p, Rect::from_center_size(pos2(zs.max.x + 14.0, footer.center().y), vec2(16.0, 16.0)), Icon::MountainLarge, t.text_dim); + // Zoom out / zoom in buttons either side of the slider: the `-` / `=` steps. Everything here + // zooms around the CTI and sets the one time zoom the layer bars and the Graph Editor share. + let cti = time.seconds(); + let cti_px = (cti - tm.start) * pps; + for (icon, size, cx, id, tip, k) in [ + (Icon::MountainSmall, 14.0, zs.min.x - 14.0, "timeline.zoomOutButton", "Zoom Out (Time)", 1.0 / ZOOM_STEP), + (Icon::MountainLarge, 16.0, zs.max.x + 14.0, "timeline.zoomInButton", "Zoom In (Time)", ZOOM_STEP), + ] { + let r = Rect::from_center_size(pos2(cx, footer.center().y), vec2(20.0, 20.0)); + let resp = ui.interact(r, egui::Id::new(id), Sense::click()).on_hover_text(crate::i18n::tr(tip)); + icons::paint(&p, Rect::from_center_size(r.center(), vec2(size, size)), icon, if resp.hovered() { t.text } else { t.text_dim }); + app.auto.add(id, r, tip); + if resp.clicked() { + set_zoom(&mut app.ui.timeline, &comp, pps * k, fit_pps, cti, cti_px); + } + } p.rect_filled(zs, 2.0, t.field_bg); - let maxpps = MAX_PPS; + let maxpps = MAX_PPS.max(fit_pps * 1.001); let frac = ((pps / fit_pps).ln() / (maxpps / fit_pps).ln()).clamp(0.0, 1.0) as f32; let knob = pos2(zs.min.x + zs.width() * frac, zs.center().y); p.circle_filled(knob, 6.0, t.text); - let zresp = ui.interact(zs.expand2(vec2(6.0, 8.0)), egui::Id::new("tl-zoom"), Sense::drag()); + // Drag the knob, or click the track to jump there. + let zresp = ui.interact(zs.expand2(vec2(6.0, 8.0)), egui::Id::new("tl-zoom"), Sense::click_and_drag()); app.auto.add("timeline.zoomSlider", zs, "Zoom"); - if zresp.dragged() + if (zresp.dragged() || zresp.clicked()) && let Some(pt) = zresp.interact_pointer_pos() { - let f = ((pt.x - zs.min.x) / zs.width()).clamp(0.0, 1.0) as f64; - let npps = fit_pps * (maxpps / fit_pps).powf(f); - let cti = time.seconds(); - let rel = (cti - tm.start) * pps; - app.ui.timeline.start = (cti - rel / npps).max(0.0); - app.ui.timeline.pps = Some(npps); + let f = ((pt.x - zs.min.x) / zs.width().max(1.0)).clamp(0.0, 1.0) as f64; + set_zoom(&mut app.ui.timeline, &comp, fit_pps * (maxpps / fit_pps).powf(f), fit_pps, cti, cti_px); } // Composition markers in the ruler; protected regions shade the layer rows. diff --git a/crates/ui-egui/tests/ui_time_zoom_controls.rs b/crates/ui-egui/tests/ui_time_zoom_controls.rs new file mode 100644 index 00000000..0bdeae1b --- /dev/null +++ b/crates/ui-egui/tests/ui_time_zoom_controls.rs @@ -0,0 +1,158 @@ +//! Time zoom in the Timeline driven over the control channel, as an agent (or a person) uses it: +//! the `=` / `-` keys, the zoom-out / zoom-in buttons and the slider in the footer, and the +//! time navigator's ends. The layer bars and the Graph Editor share one time zoom (After +//! Effects): the same controls zoom the graph's time axis, centred on the current time, and +//! switching between the views keeps it. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::control::{self, ControlRequest, Outcome}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +/// A 10 s, 30 fps comp with a layer whose Opacity is keyed at 1 s and 8 s, revealed, the CTI at +/// 4 s; the Graph Editor on when `graph`. +fn harness(graph: bool) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Zoom", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap(); + let id = s.active_comp().unwrap().layers[0].id; + for (t, v) in [(1.0, 0.0), (8.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + s.execute("layer.select", json!({"layers": [id.0]})).unwrap(); + let mut app = EffectcraftApp::new(s); + // (Opening a comp may zoom; start from the whole comp.) + app.ui.timeline.pps = None; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(3); + h.state_mut().session.execute("time.set", json!({"time": 4.0})).unwrap(); + h.state_mut().ui.timeline.pps = None; + send(&mut h, "ui.menu.invoke", json!({"command": "timeline.reveal.opacity"})); + if graph { + send(&mut h, "ui.click", json!({"id": "timeline.switch.graph"})); + } + h.run_steps(3); + assert_eq!(h.state().ui.timeline.graph_editor, graph); + h +} + +/// One control-channel request, then its synthetic input fed in one event per frame (kittest +/// doesn't call `raw_input_hook`). +fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + if let Outcome::Done(v) = control::handle(h.state_mut(), &ctx, &req) { + assert!(v["ok"] != false, "{method}: {v}"); + } + for e in h.state_mut().take_synthetic_input() { + h.event(e); + h.step(); + } + h.run_steps(3); +} + +/// Visible span (start, seconds shown) from the ruler's automation label ("start,pps"); the +/// time graph ends 18 px before the ruler and maps time from 6 px in. +fn view(h: &Harness<'_, EffectcraftApp>) -> (f64, f64) { + let e = h.state().auto.find("timeline.ruler").expect("ruler"); + let (start, pps) = e.label.split_once(',').map(|(a, b)| (a.parse::().unwrap(), b.parse::().unwrap())).unwrap(); + (start, (e.rect[2] - 24.0) as f64 / pps) +} + +fn x_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> f32 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + e.rect[0] + e.rect[2] / 2.0 +} + +/// The span shrinks (`in`) or grows by more than 10 %, and the CTI stays where it was. +fn zoomed(h: &Harness<'_, EffectcraftApp>, before: f64, cti: f32, zoom_in: bool, what: &str) -> f64 { + let (_, span) = view(h); + if zoom_in { + assert!(span < before * 0.9, "{what} zooms in: {before} -> {span}"); + } else { + assert!(span > before * 1.1, "{what} zooms out: {before} -> {span}"); + } + assert!((x_of(h, "timeline.cti") - cti).abs() < 1.5, "{what}: the CTI stays put ({cti} -> {})", x_of(h, "timeline.cti")); + span +} + +fn check_controls(graph: bool) { + let mut h = harness(graph); + let (_, span) = view(&h); + assert!((span - 10.0).abs() < 0.2, "starts at the whole comp: {span}"); + let cti = x_of(&h, "timeline.cti"); + send(&mut h, "ui.key", json!({"key": "="})); + let span = zoomed(&h, span, cti, true, "="); + send(&mut h, "ui.key", json!({"key": "="})); + let span = zoomed(&h, span, cti, true, "= again"); + send(&mut h, "ui.key", json!({"key": "-"})); + let span = zoomed(&h, span, cti, false, "-"); + send(&mut h, "ui.click", json!({"id": "timeline.zoomInButton"})); + let span = zoomed(&h, span, cti, true, "the zoom-in button"); + send(&mut h, "ui.click", json!({"id": "timeline.zoomOutButton"})); + let span = zoomed(&h, span, cti, false, "the zoom-out button"); + // A click on the slider's track jumps the zoom there. + send(&mut h, "ui.click", json!({"id": "timeline.zoomSlider", "fx": 0.9})); + zoomed(&h, span, cti, true, "a click near the slider's right end"); + send(&mut h, "ui.click", json!({"id": "timeline.zoomSlider", "fx": 0.0})); + assert_eq!(h.state().ui.timeline.pps, None, "the slider's left end shows the whole comp"); + // Zooming out stops at the whole comp. + send(&mut h, "ui.click", json!({"id": "timeline.zoomOutButton"})); + send(&mut h, "ui.key", json!({"key": "-"})); + assert_eq!(h.state().ui.timeline.pps, None, "no further out than the whole comp"); +} + +#[test] +fn keys_buttons_and_slider_zoom_the_layer_bars() { + check_controls(false); +} + +#[test] +fn keys_buttons_and_slider_zoom_the_graph_editor_time_axis() { + check_controls(true); +} + +/// In the Graph Editor the curves follow the time zoom: a key's screen distance from the CTI +/// grows with `=`, and the navigator's end handle zooms the graph too. +#[test] +fn graph_curves_follow_the_time_zoom_and_the_navigator() { + let mut h = harness(true); + let key_x = |h: &Harness<'_, EffectcraftApp>| { + let mut ks: Vec = h.state().auto.query("timeline.graph.key.").iter().map(|e| e.rect[0] + e.rect[2] / 2.0).collect(); + ks.sort_by(f32::total_cmp); + ks + }; + let ks = key_x(&h); + assert_eq!(ks.len(), 2, "both Opacity keys in the graph: {ks:?}"); + let cti = x_of(&h, "timeline.cti"); + let d0 = ks[1] - cti; + send(&mut h, "ui.key", json!({"key": "="})); + let d1 = key_x(&h).last().copied().unwrap_or(cti) - cti; + assert!(d1 > d0 * 1.3, "the 8 s key moves away from the CTI as time zooms in: {d0} -> {d1}"); + // Back to the whole comp, then the navigator's end handle dragged to the middle: 0..5 s. + send(&mut h, "ui.menu.invoke", json!({"command": "timeline.zoomFit"})); + let nav = h.state().auto.find("timeline.navigator").unwrap().rect; + let end = h.state().auto.find("timeline.navigator.end").unwrap().rect; + let y = end[1] + end[3] / 2.0; + send(&mut h, "ui.drag", json!({"from": {"x": end[0] + end[2] / 2.0, "y": y}, "to": {"x": nav[0] + nav[2] * 0.5, "y": y}})); + let (start, span) = view(&h); + assert!(start.abs() < 1e-6 && (span - 5.0).abs() < 0.3, "0..5 s: {start} + {span}"); + assert_eq!(key_x(&h).len(), 1, "the 8 s key is past the visible span"); +} + +#[test] +fn switching_between_layer_bars_and_graph_keeps_the_time_zoom() { + let mut h = harness(false); + send(&mut h, "ui.key", json!({"key": "="})); + send(&mut h, "ui.key", json!({"key": "="})); + let before = view(&h); + send(&mut h, "ui.click", json!({"id": "timeline.switch.graph"})); + assert!(h.state().ui.timeline.graph_editor); + assert_eq!(view(&h), before); + send(&mut h, "ui.key", json!({"key": "-"})); + let graph = view(&h); + send(&mut h, "ui.click", json!({"id": "timeline.switch.graph"})); + assert!(!h.state().ui.timeline.graph_editor); + assert_eq!(view(&h), graph); +}