From f936b1ff9cb5e4df4231dfb4c6afeac24c39a22e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:09:18 +0700 Subject: [PATCH] Timeline: Alt+wheel zooms out to the whole comp (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alt+wheel's zoom took its lower limit from the current zoom rather than the comp's fit and kept the ruler's start where the pointer had put it, so on long comps it stopped zoomed out on a slice from the middle of the comp (0:05:02–0:10:27 of a 10-minute comp). The = / - zoom used a different limit again. Both now go through set_zoom: the limit is the zoom that fits the comp (fit_pps, the same formula the ruler and the zoom slider use), and reaching it switches the ruler back to fitting the whole comp from 0. Test: alt_wheel_zooms_out_until_the_whole_comp_shows (real Alt+wheel events on a 10-minute comp); it failed before (Some(1.135) px/s, start still offset). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 50 ++++++++++++-------- crates/ui-egui/tests/ui_timeline_zoom.rs | 58 ++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_zoom.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 48fc72ec..ae1f7bbd 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -341,19 +341,37 @@ fn tl_map_id() -> egui::Id { egui::Id::new("timeline-map") } +/// The most a time ruler zooms in (pixels per second). +const MAX_PPS: f64 = 4000.0; + +/// 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) +} + +/// Zoom the time ruler to `pps` pixels per second, keeping comp time `at_t` at `at_px` pixels +/// from the ruler's start. Zooming out stops at `fit`, where the whole comp shows (and the +/// ruler keeps fitting it). +fn set_zoom(tl: &mut crate::state::TimelineState, comp: &Comp, pps: f64, fit: f64, at_t: f64, at_px: f64) { + let npps = pps.min(MAX_PPS.max(fit)).max(fit); + if npps <= fit { + tl.pps = None; + tl.start = 0.0; + return; + } + tl.start = (at_t - at_px / npps).clamp(0.0, comp.duration.seconds()); + tl.pps = Some(npps); +} + /// Zoom the time ruler around the CTI. pub fn zoom(app: &mut EffectcraftApp, ctx: &egui::Context, k: f64) { - let Some((x0, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; - let comp = app.session.active_comp_arc(); - let Some(comp) = comp else { return }; - let fit = (w as f64 - 20.0) / comp.duration.seconds().max(0.01); + let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; + let Some(comp) = app.session.active_comp_arc() else { return }; + let fit = fit_pps(w, &comp); let pps = app.ui.timeline.pps.unwrap_or(fit); - let npps = (pps * k).clamp(fit.min(1.0), 4000.0); let cti = app.session.time().seconds(); let rel = (cti - app.ui.timeline.start) * pps; - app.ui.timeline.start = (cti - rel / npps).max(0.0); - app.ui.timeline.pps = Some(npps); - let _ = x0; + set_zoom(&mut app.ui.timeline, &comp, pps * k, fit, cti, rel); } /// Screen point of a layer bar (centre, or at a comp time). @@ -850,7 +868,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let graph_x0 = rect.min.x + left_w + 1.0; let graph_x1 = rect.max.x - 10.0; ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0))); - let fit_pps = (graph_x1 - graph_x0 - 10.0) as f64 / comp.duration.seconds().max(0.01); + let fit_pps = fit_pps(graph_x1 - graph_x0, &comp); let pps = app.ui.timeline.pps.unwrap_or(fit_pps); if app.ui.timeline.pps.is_none() { app.ui.timeline.start = 0.0; @@ -1184,7 +1202,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); if zoom && dy.abs() > 0.0 { - zoom_at(app, &comp, tm, (dy as f64 / 200.0).exp(), ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0)); + // Alt+wheel zooms around the pointer, out until the whole comp shows. + let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); + set_zoom(&mut app.ui.timeline, &comp, tm.pps * (dy as f64 / 200.0).exp(), fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); } else { let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0); let shift = ui.input(|i| i.modifiers.shift); @@ -2286,7 +2306,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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); p.rect_filled(zs, 2.0, t.field_bg); - let maxpps = 4000.0f64; + let maxpps = MAX_PPS; 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); @@ -2644,14 +2664,6 @@ fn kind_name(k: MatteKind) -> &'static str { } } -fn zoom_at(app: &mut EffectcraftApp, comp: &Comp, tm: TMap, k: f64, at_x: f32) { - let fit = tm.pps / app.ui.timeline.pps.map(|p| p / tm.pps).unwrap_or(1.0); - let npps = (tm.pps * k).clamp(fit.min(5.0), 4000.0); - let at_t = tm.t(at_x); - app.ui.timeline.start = (at_t - (at_x - tm.x0) as f64 / npps).clamp(0.0, comp.duration.seconds()); - app.ui.timeline.pps = Some(npps); -} - fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) { resp.context_menu(|ui| layer_menu(ui, &[layer.id.0], actions)); } diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs new file mode 100644 index 00000000..9a7d3897 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -0,0 +1,58 @@ +//! Zooming the Timeline's time ruler with real input: Alt+wheel zooms out until the whole comp +//! shows (#158). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, MouseWheelUnit, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// A 10-minute comp with one solid, zoomed in on its start. +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Long", "width": 320, "height": 180, "frameRate": 30, "duration": 600})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let mut app = EffectcraftApp::new(s); + app.ui.timeline.pps = Some(200.0); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(3); + (h, layer) +} + +/// A point over the layer's bar in the time graph. +fn over_bar(h: &Harness<'_, EffectcraftApp>, layer: u64) -> Pos2 { + let e = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).expect("layer bar").clone(); + pos2(e.rect[0] + 40.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn alt_wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) { + h.event(Event::PointerMoved(at)); + h.event(Event::ModifiersChanged(Modifiers::ALT)); + h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), modifiers: Modifiers::ALT, phase: egui::TouchPhase::Move }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); +} + +/// #158: on a long comp, Alt+wheel zooms out all the way to the whole comp (and no further). +#[test] +fn alt_wheel_zooms_out_until_the_whole_comp_shows() { + let (mut h, layer) = harness(); + for _ in 0..80 { + let at = over_bar(&h, layer); + alt_wheel(&mut h, at, -120.0); + if h.state().ui.timeline.pps.is_none() { + break; + } + } + let tl = &h.state().ui.timeline; + assert_eq!((tl.pps, tl.start), (None, 0.0), "fits the whole comp: {:?}", tl.pps); + // The bar spans (nearly) the whole time graph. + let bar = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).unwrap().rect; + let ruler = h.state().auto.find("timeline.ruler").unwrap().rect; + assert!(bar[2] > ruler[2] * 0.95, "bar {bar:?} ruler {ruler:?}"); + // Zooming in again works from there. + let at = over_bar(&h, layer); + alt_wheel(&mut h, at, 240.0); + assert!(h.state().ui.timeline.pps.is_some()); +}