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:
parent
923ec0fc4a
commit
ac442f8c61
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue