mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
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 <noreply@anthropic.com> (cherry picked from commit 2ee0fb656ff6bd16eed6857be10495d9f30e3a64) (cherry picked from commit f1997d32d4c51b58467a04944889131fd741a22a) (cherry picked from commit 1c25aeb51d37357b4b37857858db4dbea246b13a) (cherry picked from commit 81d7fcccfc91f76f7b9640cbb7d3f75dcea56e30)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<f64>().unwrap(), b.parse::<f64>().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<f32> = 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);
|
||||
}
|
||||
Reference in New Issue
Block a user