ui: Graph Editor fits the selection when it opens

Opening the Graph Editor (header button, menu, Shift+F3, control channel)
frames the graphs on its first frame: Fit Selection to View when keys are
selected, else Fit All Graphs to View. Switching value/speed graph refits
too (different units). A selected key now shows its property's graph even
when its layer isn't selected, and Fit Selection on a lone key also frames
its neighbouring keys.

Co-Authored-By: Claude <noreply@anthropic.com>

(cherry picked from commit a3c95f601e78e7a12f62aba94ee3515b2ade1670)

(cherry picked from commit d6309ffad79b91676d6409d1845de508e485873d)
(cherry picked from commit 78f557433742e443c9b7db34c1dac67e6a2b8e18)

(cherry picked from commit 7a4e160434b7bf1289cbf5900a86715814d1c791)
This commit is contained in:
Elias 2026-10-09 21:44:32 +01:00 committed by Elias
parent 923ec0fc4a
commit ac442f8c61
4 changed files with 145 additions and 6 deletions

View File

@ -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::<bool>(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::<bool>(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::<bool>(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);

View File

@ -1760,6 +1760,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut ui_actions: Vec<UiAct> = 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));

View File

@ -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);
}

View File

@ -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();