diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d94586bf..28e28835 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -43,7 +43,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.theme.light", "Theme: Light", [], None), uic!("timeline.zoomIn", "Zoom In Time", [], Some("=")), uic!("timeline.zoomOut", "Zoom Out Time", [], Some("-")), - uic!("timeline.zoomFit", "Zoom to Fit Comp", [], Some(";")), + uic!("timeline.zoomFit", "Zoom to Fit Comp", [], None), + uic!("timeline.zoomFrameToggle", "Zoom In to Frame Level / Out to the Whole Comp", [], Some(";")), uic!("timeline.graphEditor", "Graph Editor", [], Some("Shift+F3")), uic!("timeline.switchesModes", "Toggle Switches / Modes", [], Some("F4")), uic!("timeline.workAreaBegin", "Set Work Area Begin", [], Some("B")), @@ -440,6 +441,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return Ok(Value::Null); } "timeline.zoomFit" => app.ui.timeline.pps = None, + "timeline.zoomFrameToggle" => crate::panels::timeline::toggle_frame_zoom(app, ctx), "timeline.column" => { let col = params.get("column").and_then(Value::as_str).ok_or("timeline.column: need `column`")?; let on = params.get("visible").and_then(Value::as_bool).unwrap_or(!crate::panels::timeline::column_visible(&app.ui.timeline, col)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ae1f7bbd..9fb77eab 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -363,6 +363,19 @@ fn set_zoom(tl: &mut crate::state::TimelineState, comp: &Comp, pps: f64, fit: f6 tl.pps = Some(npps); } +/// `;`: from the whole comp, zoom in to frame level around the CTI; otherwise show the whole comp. +pub fn toggle_frame_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) { + 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); + if app.ui.timeline.pps.is_some_and(|p| p > fit) { + set_zoom(&mut app.ui.timeline, &comp, fit, fit, 0.0, 0.0); + return; + } + let cti = app.session.time().seconds(); + set_zoom(&mut app.ui.timeline, &comp, MAX_PPS, fit, cti, w as f64 / 2.0); +} + /// Zoom the time ruler around the CTI. pub fn zoom(app: &mut EffectcraftApp, ctx: &egui::Context, k: f64) { let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; @@ -953,6 +966,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for x in [nav_vis.min.x, nav_vis.max.x] { p.rect_filled(Rect::from_min_max(pos2(x - 3.0, nav.min.y - 1.0), pos2(x + 3.0, nav.max.y + 1.0)), 3.0, t.accent); } + // The middle scrolls at the current zoom; each end is a handle that moves that side of the + // visible span (pulled in: zoomed in; stretched to the whole track: the whole comp). let nresp = ui.interact(nav, egui::Id::new("tl-nav"), Sense::drag()); app.auto.add("timeline.navigator", nav, "Time navigator"); if nresp.dragged() { @@ -962,6 +977,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.timeline.pps = Some(pps); } } + let span_px = (graph_x1 - tm.x0) as f64; + let (t0, t1) = (tm.start, tm.t(graph_x1)); + for (end, x) in [("start", nav_vis.min.x), ("end", nav_vis.max.x)] { + let hr = Rect::from_center_size(pos2(x, nav.center().y), vec2(10.0, nav.height() + 6.0)); + let hresp = ui.interact(hr, egui::Id::new(("tl-nav", end)), Sense::drag()); + app.auto.add(&format!("timeline.navigator.{end}"), hr, if end == "start" { "Time navigator start" } else { "Time navigator end" }); + if hresp.hovered() || hresp.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + if let Some(px) = hresp.interact_pointer_pos().filter(|_| hresp.dragged()) { + let t = ((px.x - nav.min.x) / nav.width()) as f64 * comp.duration.seconds(); + // The other end stays; the span is at least what the most zoomed-in ruler shows. + let min_span = span_px / MAX_PPS; + let (keep_t, keep_px, span) = if end == "start" { (t1, span_px, t1 - t.min(t1 - min_span)) } else { (t0, 0.0, t.max(t0 + min_span) - t0) }; + set_zoom(&mut app.ui.timeline, &comp, span_px / span.max(min_span), fit_pps, keep_t, keep_px); + } + } p.rect_filled(ruler, 0.0, t.tl_ruler_bg); app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps)); // Work area bar: below the ruler, beside the column headers (After Effects' layout), with diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 9a7d3897..460375a5 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -1,5 +1,6 @@ //! Zooming the Timeline's time ruler with real input: Alt+wheel zooms out until the whole comp -//! shows (#158). +//! shows (#158); the Time Navigator's ends drag to zoom and `;` toggles frame level / the whole +//! comp (#159). use effectcraft_engine::Session; use effectcraft_ui_egui::EffectcraftApp; @@ -56,3 +57,70 @@ fn alt_wheel_zooms_out_until_the_whole_comp_shows() { alt_wheel(&mut h, at, 240.0); assert!(h.state().ui.timeline.pps.is_some()); } + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// The visible span (seconds) from the ruler's automation label ("start,pps") and width. +fn visible(h: &Harness<'_, EffectcraftApp>) -> (f64, f64) { + let e = h.state().auto.find("timeline.ruler").unwrap().clone(); + let (start, pps) = e.label.split_once(',').map(|(a, b)| (a.parse::().unwrap(), b.parse::().unwrap())).unwrap(); + (start, start + (e.rect[2] - 16.0) as f64 / pps) +} + +/// #159: dragging the navigator's end handles changes that side of the visible span; pulling +/// them out to the track's ends shows the whole comp. +#[test] +fn navigator_ends_drag_to_zoom() { + let (mut h, _) = harness(); + h.state_mut().ui.timeline.pps = None; + h.run_steps(3); + let track = rect(&h, "timeline.navigator"); + // The end handle pulled to the middle: the span ends near half the comp, starts at 0. + let end = rect(&h, "timeline.navigator.end").center(); + drag(&mut h, end, pos2(track.center().x, end.y)); + let (a, b) = visible(&h); + assert!(a.abs() < 1e-6 && (b - 300.0).abs() < 15.0, "{a}..{b}"); + // The start handle pulled to a quarter: the span starts there, its end stays. + let start = rect(&h, "timeline.navigator.start").center(); + drag(&mut h, start, pos2(track.min.x + track.width() * 0.25, start.y)); + let (a2, b2) = visible(&h); + assert!((a2 - 150.0).abs() < 15.0 && (b2 - b).abs() < 2.0, "{a2}..{b2} (end was {b})"); + // Both ends back out to the track's ends: the whole comp. + let start = rect(&h, "timeline.navigator.start").center(); + drag(&mut h, start, pos2(track.min.x - 20.0, start.y)); + let end = rect(&h, "timeline.navigator.end").center(); + drag(&mut h, end, pos2(track.max.x + 20.0, end.y)); + assert_eq!(h.state().ui.timeline.pps, None, "the whole comp"); +} + +/// #159: `;` zooms in to frame level around the current time, and back out to the whole comp. +#[test] +fn semicolon_toggles_frame_level_and_the_whole_comp() { + let (mut h, _) = harness(); + h.state_mut().ui.timeline.pps = None; + h.state_mut().session.execute("time.set", json!({"time": 120.0})).unwrap(); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFrameToggle", json!({})).unwrap(); + h.run_steps(2); + let (a, b) = visible(&h); + assert!(a < 120.0 && 120.0 < b && b - a < 1.0, "frame level around the CTI: {a}..{b}"); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFrameToggle", json!({})).unwrap(); + assert_eq!(h.state().ui.timeline.pps, None); +}