mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Timeline: the Time Navigator's ends drag to zoom; ; toggles frame level (#159)
The navigator drew handles on both ends but the whole bar was one drag area that scrolled. Each end is now a handle (timeline.navigator.start / .end) that moves that side of the visible span while the other stays: pulled in, the ruler zooms in; stretched to the track's ends, it shows the whole comp again. The middle still scrolls at the current zoom. ; (timeline.zoomFrameToggle) zooms in to frame level around the current time and, pressed again, back out to the whole comp. timeline.zoomFit stays as the plain fit command (no shortcut). Both go through set_zoom. Tests: navigator_ends_drag_to_zoom and semicolon_toggles_frame_level_and_the_whole_comp (real pointer drags). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f936b1ff9c
commit
bfd1e974ea
3 files changed
+104
-2
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<f64>().unwrap(), b.parse::<f64>().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);
|
||||
}
|
||||
Reference in new issue
Block a user