diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 77e39c62..ea3e1431 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -82,11 +82,19 @@ fn speed_graph(app: &EffectcraftApp, comp: &Comp) -> bool { fn shown_props(app: &EffectcraftApp, comp: &Comp) -> Vec<(LayerId, u64)> { let st = &app.session.state; let numeric = |pr: &Property| !pr.value.components().is_empty() && pr.value.interpolates(); - if app.ui.timeline.graph_show_selected && !st.selected_props.is_empty() { - return st.selected_props.iter().filter(|(l, u)| comp.layer(*l).and_then(|l| l.props.find(*u)).is_some_and(numeric)).copied().collect(); + // A selected keyframe counts as selecting its property and layer (as clicking a key does in + // After Effects), so selecting a key in the Timeline shows its graph. + let mut picked: Vec<(LayerId, u64)> = st.selected_props.clone(); + for k in &st.selected_keys { + if !picked.contains(&(k.layer, k.prop)) { + picked.push((k.layer, k.prop)); + } + } + if app.ui.timeline.graph_show_selected && !picked.is_empty() { + return picked.into_iter().filter(|(l, u)| comp.layer(*l).and_then(|l| l.props.find(*u)).is_some_and(numeric)).collect(); } let mut v = vec![]; - for l in comp.layers.iter().filter(|l| st.selected_layers.contains(&l.id)) { + for l in comp.layers.iter().filter(|l| st.selected_layers.contains(&l.id) || picked.iter().any(|(pl, _)| *pl == l.id)) { l.props.walk("", &mut |_, pr| { if (pr.is_animated() || pr.has_expression()) && numeric(pr) { v.push((l.id, pr.uid)); @@ -275,6 +283,18 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone()); let speed = speed_graph(app, comp); let cs = curves(app, comp, ectx, tm, plot, speed); + // Just opened, or the graph type changed (value and speed have different units): frame the + // graphs (Fit Selection to View, else Fit All Graphs to View). + let was_speed_id = egui::Id::new("graph-was-speed"); + let type_changed = ctx.data(|d| d.get_temp::(was_speed_id)).is_some_and(|was| was != speed); + ctx.data_mut(|d| d.insert_temp(was_speed_id, speed)); + if ctx.data_mut(|d| d.remove_temp::(fit_pending_id())).unwrap_or(false) || type_changed { + if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) { + fit_selection(app, &cs, tm, plot); + } else { + fit_all(app); + } + } // Vertical range: auto-zoom to the curves + handles, or the stored manual range. let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); @@ -558,8 +578,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.ui.timeline.graph_auto_zoom = !auto_on; } if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") { - app.ui.timeline.graph_auto_zoom = true; - app.ui.timeline.pps = None; + fit_all(app); } if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { fit_selection(app, &cs, tm, plot); @@ -599,6 +618,30 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte buttons.more(app, ui, p, bar); } +/// Fit All Graphs to View: the whole comp in time, auto-zoomed values. +fn fit_all(app: &mut EffectcraftApp) { + app.ui.timeline.graph_auto_zoom = true; + app.ui.timeline.pps = None; +} + +fn fit_pending_id() -> egui::Id { + egui::Id::new("graph-fit-pending") +} + +/// Called every Timeline frame with whether the Graph Editor is on: switching it on (header +/// button, menu, Shift+F3 or the control channel) frames the graphs on its first frame, when the +/// plot rect is known, as After Effects does. +pub(crate) fn track_open(ctx: &egui::Context, on: bool) { + let was_id = egui::Id::new("graph-editor-was-on"); + let was = ctx.data(|d| d.get_temp::(was_id)).unwrap_or(false); + ctx.data_mut(|d| { + d.insert_temp(was_id, on); + if on && !was { + d.insert_temp(fit_pending_id(), true); + } + }); +} + /// Zoom time and value to the selected keys (and their handles). fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect) { let mut t0 = f64::INFINITY; @@ -618,6 +661,20 @@ fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect) { if !t0.is_finite() { return; } + // A lone selected key (one point) also frames its neighbouring keys, so its curve shows + // instead of a flat sliver a fraction of a frame wide. + if t1 - t0 < 1e-6 { + for c in cs { + for (i, k) in c.keys.iter().enumerate().filter(|(_, k)| k.sel) { + for n in [i.checked_sub(1).and_then(|j| c.keys.get(j)), c.keys.get(i + 1), Some(k)].into_iter().flatten() { + t0 = t0.min(n.t); + t1 = t1.max(n.t); + v0 = v0.min(n.v); + v1 = v1.max(n.v); + } + } + } + } let span = (t1 - t0).max(0.2); let pps = (plot.width() as f64 * 0.8 / span).clamp(5.0, 4000.0); app.ui.timeline.pps = Some(pps); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index f2096cdb..b2b00b10 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1760,6 +1760,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut ui_actions: Vec = Vec::new(); let idx_of = |id: LayerId| comp.index_of(id).unwrap_or(0); let graph_on = app.ui.timeline.graph_editor; + super::graph::track_open(ui.ctx(), graph_on); let full_clip = ui.clip_rect(); let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y))); let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); diff --git a/crates/ui-egui/tests/ui_graph_fit.rs b/crates/ui-egui/tests/ui_graph_fit.rs new file mode 100644 index 00000000..c161dfef --- /dev/null +++ b/crates/ui-egui/tests/ui_graph_fit.rs @@ -0,0 +1,79 @@ +//! Opening the Graph Editor frames the graphs, as in After Effects: Fit Selection to View when +//! keys are selected (a key selected in the Timeline shows its property's graph even when the +//! layer isn't selected), else Fit All Graphs to View. It used to open on an empty 0–100 range +//! or with the curves off-view. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +/// A 20 s comp whose Box layer has Rotation keys 300° at 8 s and 720° at 12 s; the Timeline is +/// zoomed in on 0–4 s, so neither key is in view. Returns the harness and Rotation's uid. +fn harness(select_layer: bool) -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Fit", "width": 320, "height": 180, "frameRate": 30, "duration": 20})).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 [(8.0, 300.0), (12.0, 720.0)] { + s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/rotation", "time": t, "value": v})).unwrap(); + } + let uid = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/rotation").unwrap().uid; + s.execute("layer.select", json!({"layers": if select_layer { vec![id.0] } else { vec![] }})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.pps = Some(300.0); + h.state_mut().ui.timeline.start = 0.0; + h.run_steps(3); + (h, id.0, uid) +} + +fn open_graph(h: &mut Harness<'_, EffectcraftApp>) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.graphEditor", json!({})).unwrap(); + h.run_steps(4); + assert!(h.state().ui.timeline.graph_editor); +} + +/// Every key of the property sits inside the plot, and the value range spans `lo..=hi`. +fn assert_framed(h: &Harness<'_, EffectcraftApp>, uid: u64, keys: &[usize], lo: f64, hi: f64) { + let plot = h.state().auto.find("timeline.graph").expect("graph plot").rect; + for i in keys { + let k = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap_or_else(|| panic!("graph key {i} not drawn")).rect; + let (cx, cy) = (k[0] + k[2] / 2.0, k[1] + k[3] / 2.0); + assert!(cx > plot[0] && cx < plot[0] + plot[2] && cy > plot[1] && cy < plot[1] + plot[3], "key {i} at ({cx}, {cy}) outside plot {plot:?}"); + } + let (a, b) = h.state().ui.timeline.graph_range.expect("graph range"); + assert!(a <= lo && b >= hi, "value range {a}..{b} doesn't contain {lo}..{hi}"); +} + +#[test] +fn opening_the_graph_editor_fits_the_selected_key() { + let (mut h, layer, uid) = harness(false); + h.state_mut().session.execute("keys.select", json!({"keys": [{"layer": layer, "prop": uid, "time": 12.0}]})).unwrap(); + h.run_steps(2); + open_graph(&mut h); + // A lone key also frames its neighbour, so its curve shows. + assert_framed(&h, uid, &[0, 1], 300.0, 720.0); + assert!(!h.state().ui.timeline.graph_auto_zoom, "Fit Selection turns auto-zoom off"); +} + +#[test] +fn opening_the_graph_editor_with_no_keys_selected_fits_all_graphs() { + let (mut h, _, uid) = harness(true); + open_graph(&mut h); + assert_eq!(h.state().ui.timeline.pps, None, "the whole comp in time"); + assert_framed(&h, uid, &[0, 1], 300.0, 720.0); + // Zooming away and toggling the Graph Editor off and on frames it again. + h.state_mut().ui.timeline.pps = Some(300.0); + h.state_mut().ui.timeline.start = 0.0; + open_graph_toggle_twice(&mut h); + assert_framed(&h, uid, &[0, 1], 300.0, 720.0); +} + +fn open_graph_toggle_twice(h: &mut Harness<'_, EffectcraftApp>) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.graphEditor", json!({})).unwrap(); + h.run_steps(2); + assert!(!h.state().ui.timeline.graph_editor); + open_graph(h); +} diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 74352824..a9adfc7b 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -168,10 +168,12 @@ fn a_key_clicked_in_the_timeline_shows_in_the_graph_editor() { #[test] fn graph_editor_drags_move_every_selected_key_and_shift_keeps_an_axis() { let (mut h, id, uid) = harness(); + // Opened before selecting, so it fits all graphs (the whole comp) rather than the two keys. + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(2); let s = &mut h.state_mut().session; s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap(); s.execute("keys.selectAll", json!({})).unwrap(); - h.state_mut().ui.timeline.graph_editor = true; h.run_steps(4); let at = |h: &Harness<'_, EffectcraftApp>, i: usize| { let e = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap_or_else(|| panic!("no graph key {i}")).clone();