Files
effectcraft/crates/ui-egui/src/panels/graph.rs
T

730 lines
33 KiB
Rust

//! The Graph Editor (Timeline ▸ Shift+F3): value graph and speed graph of the selected (or all
//! animated) properties, keyframes you can drag in time and value, Bezier/influence handles on
//! selected keys, auto-zoom / fit, and the bottom button bar (graph type, show selected,
//! auto-zoom, fit, separate dimensions, hold/linear/auto Bezier, Easy Ease family).
//!
//! Every edit is an engine command (`keys.set`, `keys.setEase`, `keys.select`, `keys.*`).
use effectcraft_engine::KeyRef;
use effectcraft_engine::keyframe::{Interp, Value, side_ease};
use effectcraft_engine::project::{Comp, Layer, LayerId, Property};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use super::timeline::TMap;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
type Actions = Vec<(String, serde_json::Value)>;
const DIM_COLORS: [Color32; 4] =
[Color32::from_rgb(0xe0, 0x55, 0x55), Color32::from_rgb(0x5c, 0xc8, 0x5c), Color32::from_rgb(0x50, 0x8c, 0xf0), Color32::from_rgb(0xe8, 0xc8, 0x40)];
const PROP_COLORS: [Color32; 6] = [
Color32::from_rgb(0xe8, 0xc8, 0x40),
Color32::from_rgb(0xd0, 0x70, 0xe0),
Color32::from_rgb(0x40, 0xc8, 0xc8),
Color32::from_rgb(0xf0, 0x90, 0x40),
Color32::from_rgb(0x9c, 0xd8, 0x50),
Color32::from_rgb(0xf0, 0x70, 0x90),
];
struct Handle {
out: bool,
/// Comp seconds / graph value of the handle end.
t: f64,
v: f64,
/// Original speed (sign is kept when dragging in the speed graph).
speed: f64,
/// Segment duration (layer seconds) the influence is relative to.
dur: f64,
}
struct KeyPt {
idx: usize,
time: Tick,
/// Comp seconds.
t: f64,
v: f64,
sel: bool,
handles: Vec<Handle>,
}
struct Curve<'a> {
layer: &'a Layer,
prop: &'a Property,
dim: usize,
color: Color32,
pts: Vec<(f64, f64)>,
keys: Vec<KeyPt>,
/// Values editable in the value graph (not spatial / combined dimensions).
editable: bool,
}
/// Properties shown: the selected ones, or every animated property of the selected layers.
/// Whether the speed graph shows: Edit Speed Graph, or Auto-Select Graph Type with only spatial
/// properties shown (Position, Anchor Point… move along a path: their speed is what to edit).
fn speed_graph(app: &EffectcraftApp, comp: &Comp) -> bool {
match app.ui.timeline.graph_mode.as_str() {
"speed" => true,
"value" => false,
_ => {
let props = shown_props(app, comp);
!props.is_empty()
&& props.iter().all(|(lid, uid)| {
comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(|pr| pr.spatial && matches!(pr.value, Value::Vec2(_) | Value::Vec3(_)))
})
}
}
}
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();
}
let mut v = vec![];
for l in comp.layers.iter().filter(|l| st.selected_layers.contains(&l.id)) {
l.props.walk("", &mut |_, pr| {
if (pr.is_animated() || pr.has_expression()) && numeric(pr) {
v.push((l.id, pr.uid));
}
});
}
v
}
fn shown_dims(l: &Layer, pr: &Property) -> usize {
let n = pr.value.components().len();
if pr.shown_dims > 0 && !l.is_3d() { (pr.shown_dims as usize).min(n) } else { n.min(4) }
}
/// Build the curves for the visible time span.
fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, plot: Rect, speed: bool) -> Vec<Curve<'a>> {
let sel = &app.session.state.selected_keys;
let n = ((plot.width() / 2.0) as usize).max(2);
let mut out = vec![];
for (pi, (lid, uid)) in shown_props(app, comp).into_iter().enumerate() {
let Some(l) = comp.layer(lid) else { continue };
let Some(pr) = l.props.find(uid) else { continue };
let spatial = pr.spatial && matches!(pr.value, Value::Vec2(_) | Value::Vec3(_));
let dims = if speed && spatial { 1 } else { shown_dims(l, pr) };
for d in 0..dims {
let color = if dims > 1 { DIM_COLORS[d % 4] } else { PROP_COLORS[pi % PROP_COLORS.len()] };
let mut pts = Vec::with_capacity(n);
for i in 0..n {
let x = plot.min.x + plot.width() * i as f32 / (n - 1) as f32;
let ct = tm.t(x);
let tt = Tick::from_seconds_f64(ct);
let v = if speed {
let lt = l.layer_time(tt);
if spatial {
effectcraft_engine::keyframe::speed(&pr.keys, lt, true)
} else {
effectcraft_engine::keyframe::velocity(&pr.keys, lt, false).get(d).copied().unwrap_or(0.0).abs()
}
} else {
ectx.at(tt).value(l, pr).components().get(d).copied().unwrap_or(0.0)
};
pts.push((ct, v));
}
let mut keys = vec![];
for (i, k) in pr.keys.iter().enumerate() {
let ks = sel.contains(&KeyRef { layer: lid, prop: uid, time: k.time });
let kt = l.comp_time(k.time).seconds();
let ed = if spatial { 0 } else { d };
let mut handles = vec![];
let mut side_speeds = vec![];
for out_side in [false, true] {
let Some(e) = side_ease(&pr.keys, i, ed, pr.spatial, out_side) else { continue };
let j = if out_side { i + 1 } else { i - 1 };
let dur = (pr.keys[j].time.seconds() - k.time.seconds()).abs();
side_speeds.push(e.speed.abs());
let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 };
let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) };
let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier };
if ks && show && (bezier || speed) {
handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur });
}
}
let v = if speed {
if side_speeds.is_empty() { 0.0 } else { side_speeds.iter().sum::<f64>() / side_speeds.len() as f64 }
} else {
k.value.components().get(d).copied().unwrap_or(0.0)
};
keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles });
}
out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial });
}
}
out
}
/// The Graph Editor's button bar, laid out left to right. Buttons that would run past the right
/// edge (a Timeline narrower than the whole row, #457) go to the "»" menu at its end instead of
/// being clipped, so every one stays reachable.
struct ButtonBar {
x: f32,
/// Where the next button must end to stay on the bar (the "»" button's room is kept free).
limit: f32,
/// Buttons moved to the "»" menu: (automation id, label, on, tooltip).
hidden: Vec<(String, String, bool, String)>,
/// The id picked from the "»" menu last frame, run by its button this frame.
chosen: Option<String>,
}
/// The "»" menu's popup and the id it picked (read by the next frame's buttons).
const MORE_POPUP: &str = "graph-more";
impl ButtonBar {
const MORE_W: f32 = 24.0;
fn new(ctx: &egui::Context, bar: Rect) -> Self {
let chosen = ctx.data_mut(|d| d.remove_temp::<String>(egui::Id::new(MORE_POPUP).with("chosen")));
ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen }
}
/// The "»" button and its menu, when buttons did not fit.
fn more(&mut self, app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect) {
if self.hidden.is_empty() {
return;
}
let t = app.tokens;
let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0));
let pid = egui::Id::new(MORE_POPUP);
let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More"));
p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) });
p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text);
app.auto.add("timeline.graph.more", r, "More");
if resp.clicked() {
let open = widgets::popup_is_open(ui, pid);
ui.data_mut(|d| d.insert_temp(pid.with("open"), !open));
}
if !widgets::popup_is_open(ui, pid) {
return;
}
let rows = self.hidden.len();
let pos = r.left_top() - vec2(0.0, rows as f32 * 22.0 + 18.0);
let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| {
let mut chosen = None;
for (id, label, on, tip) in &self.hidden {
let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str());
app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip);
if item.clicked() {
chosen = Some(id.clone());
}
}
chosen
});
if let Some(id) = chosen {
ui.data_mut(|d| d.insert_temp(pid.with("chosen"), id));
ui.ctx().request_repaint();
}
}
}
fn text_button(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
bar: &mut ButtonBar,
y: f32,
label: &str,
on: bool,
id: &str,
tip: &str,
) -> bool {
let t = app.tokens;
let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0;
// Once one button overflows, the rest follow it into the menu (they keep their order).
if !bar.hidden.is_empty() || bar.x + w > bar.limit {
bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string()));
return bar.chosen.as_deref() == Some(id);
}
let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0));
let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip);
p.rect_filled(
r,
3.0,
if on {
t.accent.gamma_multiply(0.55)
} else if resp.hovered() {
t.hover
} else {
Color32::from_rgb(0x2c, 0x2c, 0x2c)
},
);
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text });
app.auto.add(&format!("timeline.graph.{id}"), r, tip);
bar.x += w + 4.0;
resp.clicked()
}
/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect, actions: &mut Actions) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let footer_h = 28.0;
let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
// Background first so keys and handles (added later) sit on top for hit-testing.
let bg = ui.interact(plot, egui::Id::new("graph-bg"), Sense::click_and_drag());
p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
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);
// Vertical range: auto-zoom to the curves + handles, or the stored manual range.
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &cs {
for (_, v) in &c.pts {
lo = lo.min(*v);
hi = hi.max(*v);
}
for k in &c.keys {
for v in std::iter::once(k.v).chain(k.handles.iter().map(|h| h.v)) {
lo = lo.min(v);
hi = hi.max(v);
}
}
}
if !lo.is_finite() {
(lo, hi) = (0.0, 100.0);
}
if speed {
lo = lo.min(0.0);
}
if (hi - lo).abs() < 1e-9 {
hi = lo + 1.0;
}
let pad = (hi - lo) * 0.08;
let auto = (lo - pad, hi + pad);
// While a key or handle is being dragged the scale stays put (no feedback loop).
let drag_id = egui::Id::new("graph-dragging");
let dragging = ctx.data(|d| d.get_temp::<bool>(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down());
let (lo, hi) = if app.ui.timeline.graph_auto_zoom && !dragging { auto } else { app.ui.timeline.graph_range.unwrap_or(auto) };
if app.ui.timeline.graph_auto_zoom && !dragging {
app.ui.timeline.graph_range = Some((lo, hi));
}
let ymap = |v: f64| plot.max.y - ((v - lo) / (hi - lo)) as f32 * plot.height();
let vmap = |y: f32| lo + (plot.max.y - y) as f64 / plot.height() as f64 * (hi - lo);
// Grid + value labels.
let pc = p.with_clip_rect(plot);
let step = nice_step((hi - lo) / 6.0);
let mut gv = (lo / step).ceil() * step;
while gv <= hi {
let y = ymap(gv);
pc.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], Stroke::new(1.0, Color32::from_white_alpha(12)));
pc.text(pos2(plot.min.x + 4.0, y - 1.0), Align2::LEFT_BOTTOM, fmt(gv, step), Tokens::ui(10.0), t.text_faint);
gv += step;
}
if cs.is_empty() {
p.text(
plot.center(),
Align2::CENTER_CENTER,
crate::i18n::tr("Select animated properties to show them in the Graph Editor"),
Tokens::ui(12.0),
t.text_faint,
);
}
// Show Reference Graph: the other graph type, faint, scaled to the plot.
if app.ui.timeline.graph_reference && !cs.is_empty() {
let other = curves(app, comp, ectx, tm, plot, !speed);
let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &other {
for (_, v) in &c.pts {
rlo = rlo.min(*v);
rhi = rhi.max(*v);
}
}
if rlo.is_finite() {
let span = (rhi - rlo).max(1e-9);
let lines: Vec<(Color32, Vec<Pos2>)> = other
.iter()
.map(|c| {
(
c.color,
c.pts
.iter()
.map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05))
.collect(),
)
})
.collect();
super::graph_tools::reference_graph(&pc, plot, &lines);
}
}
// Curves.
for c in &cs {
let pts: Vec<Pos2> = c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), ymap(*v))).collect();
pc.add(egui::Shape::line(pts, Stroke::new(1.5, c.color)));
}
// Transform box around several selected keys (inside below the keys, handles above).
let sel_keys: Vec<super::graph_tools::SelKey> = cs
.iter()
.flat_map(|c| c.keys.iter().filter(|k| k.sel).map(move |k| super::graph_tools::SelKey { t: k.t, v: k.v, dim: c.dim, editable: c.editable }))
.collect();
let all_key_times: Vec<(u64, usize, f64)> = cs.iter().flat_map(|c| c.keys.iter().map(move |k| (c.prop.uid, k.idx, k.t))).collect();
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions);
// Keys and handles.
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
for c in &cs {
let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim);
for k in &c.keys {
let kp = pos2(tm.x(k.t), ymap(k.v));
// In the speed graph an end key rises with the handle being dragged; skipping it once it
// leaves the plot would drop the handle's widget and end the drag at the top edge.
let handle_dragged = || ["in", "out"].iter().any(|side| ctx.is_being_dragged(egui::Id::new(("ghandle", uid, d, k.idx, *side))));
if !plot.expand(6.0).contains(kp) && !handle_dragged() {
continue;
}
let kjson = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds()});
key_screens.push((kjson.clone(), kp));
// Handles (selected keys).
for h in &k.handles {
let hp = pos2(tm.x(h.t), ymap(h.v));
pc.line_segment([kp, hp], Stroke::new(1.0, Color32::from_rgb(0xd8, 0xd8, 0x60)));
pc.circle_filled(hp, 3.5, Color32::from_rgb(0xd8, 0xd8, 0x60));
let side = if h.out { "out" } else { "in" };
let hr = Rect::from_center_size(hp, vec2(10.0, 10.0));
let hresp = ui.interact(hr, egui::Id::new(("ghandle", uid, d, k.idx, side)), Sense::drag());
app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle");
// The same side's handles of every selected key move with it, by as much as the
// pointer moved (After Effects), in one undo step.
if hresp.drag_started() {
let grabs: Vec<HandleGrab> = cs
.iter()
.flat_map(|c2| {
c2.keys.iter().flat_map(move |k2| {
k2.handles.iter().filter(|h2| h2.out == h.out).map(move |h2| HandleGrab {
layer: c2.layer.id,
prop: c2.prop.uid,
dim: c2.editable.then_some(c2.dim),
time: k2.time,
out: h2.out,
key: (k2.t, k2.v),
end: (h2.t, h2.v),
negative: h2.speed < 0.0,
dur: h2.dur,
})
})
})
.collect();
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(hp);
ctx.data_mut(|dd| dd.insert_temp::<HandleDrag>(handle_drag_id(), (press, grabs)));
}
if hresp.dragged()
&& let Some(pt) = hresp.interact_pointer_pos()
&& let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::<HandleDrag>(handle_drag_id()))
{
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
// Shift keeps the handle on the time or the value axis, whichever moved more (as for keys).
let (time_ok, value_ok) = shift_axes(ui.input(|i| i.modifiers.shift), pt.x - press.x, pt.y - press.y);
let dt = if time_ok { tm.t(pt.x) - tm.t(press.x) } else { 0.0 };
let dv = if value_ok { vmap(pt.y) - vmap(press.y) } else { 0.0 };
let merge = format!("ghandle-{uid}-{}", k.idx);
for g in &grabs {
actions.push(("keys.setEase".into(), g.ease(g.end.0 + dt, g.end.1 + dv, speed, &merge)));
}
}
if hresp.drag_stopped() {
ctx.data_mut(|dd| {
dd.insert_temp(drag_id, false);
dd.remove::<HandleDrag>(handle_drag_id());
});
actions.push(("__endMerge".into(), json!({})));
}
}
// The key itself.
let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) };
pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col);
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside);
let kr = Rect::from_center_size(kp, vec2(11.0, 11.0));
let kresp = ui.interact(kr, egui::Id::new(("gkey", uid, d, k.idx)), Sense::click_and_drag());
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
let mods = ui.input(|i| i.modifiers);
let key = &c.prop.keys[k.idx];
if kresp.clicked() && mods.command {
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off (as in the Timeline).
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()]})));
if mods.alt {
actions.push(("keys.toggleHold".into(), json!({})));
} else {
let linear = key.in_interp == Interp::Linear && key.out_interp == Interp::Linear;
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
}
} else if kresp.clicked() && mods.shift {
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true})));
} else if (kresp.clicked() || kresp.drag_started()) && !k.sel {
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift})));
}
// A drag moves every selected key, by how far the pointer went from where the button
// went down (the key doesn't jump under the pointer); Shift keeps it to the time or
// the value axis, whichever moved more.
if kresp.drag_started()
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
{
ctx.data_mut(|dd| dd.insert_temp(key_grab_id(), KeyGrab { press: o, t: k.t }));
}
if kresp.dragged()
&& let Some(pt) = kresp.interact_pointer_pos()
&& let Some(g) = ctx.data(|dd| dd.get_temp::<KeyGrab>(key_grab_id()))
{
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
let (dx, dy) = (pt.x - g.press.x, pt.y - g.press.y);
let (time_ok, value_ok) = shift_axes(mods.shift, dx, dy);
// Graph Editor ▸ Snap: to the current time and other keys.
let others: Vec<f64> = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect();
let t = if time_ok { super::graph_tools::snap_time(app, tm, (g.t + (tm.t(pt.x) - tm.t(g.press.x))).max(0.0), &others) } else { g.t };
let mut params = json!({"timeOffset": t - g.t, "merge": "graph-key", "fromStart": true});
if value_ok && !speed && c.editable {
params["valueOffset"] = json!(vmap(pt.y) - vmap(g.press.y));
params["dim"] = json!(d);
}
actions.push(("keys.transform".into(), params));
}
if kresp.drag_stopped() {
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
actions.push(("__endMerge".into(), json!({})));
}
}
}
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions);
// Background: click deselects keys, drag box-selects.
if bg.clicked() {
actions.push(("keys.select".into(), json!({"keys": []})));
}
if bg.dragged()
&& let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
{
let br = Rect::from_two_pos(o, cur);
pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
ctx.data_mut(|d| d.insert_temp(egui::Id::new("graph-box"), br));
}
if bg.drag_stopped()
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("graph-box")))
{
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::new("graph-box")));
let keys: Vec<serde_json::Value> = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
}
// Wheel over the graph with Alt/Ctrl zooms the value axis (turns auto-zoom off).
if ui.rect_contains_pointer(plot) {
let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
if zoom && dy.abs() > 0.0 {
let k = (-dy as f64 / 200.0).exp();
let mid = (lo + hi) / 2.0;
app.ui.timeline.graph_auto_zoom = false;
app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
}
}
// Button bar.
p.rect_filled(bar, 0.0, t.panel_bg);
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
let y = bar.center().y;
let mut buttons = ButtonBar::new(&ctx, bar);
let tl = &app.ui.timeline;
let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom);
if text_button(
app,
ui,
p,
&mut buttons,
y,
"Auto",
auto_type,
"autoSelectGraphType",
"Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
) {
app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into();
}
// Choosing a graph type turns Auto-Select off, as in After Effects.
if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") {
app.ui.timeline.graph_mode = "value".into();
}
if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") {
app.ui.timeline.graph_mode = "speed".into();
}
buttons.x += 6.0;
if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") {
app.ui.timeline.graph_show_selected = !show_sel;
}
if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") {
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;
}
if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") {
fit_selection(app, &cs, tm, plot);
}
buttons.x += 6.0;
let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
app.ui.timeline.graph_snap = !snap;
}
if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") {
app.ui.timeline.graph_reference = !reference;
}
if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
app.ui.timeline.graph_transform_box = !tbox;
}
buttons.x += 6.0;
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0));
if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
&& let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
{
actions.push(("prop.separateDimensions".into(), json!({"layer": l})));
}
buttons.x += 6.0;
for (label, cmd, params, tip) in [
("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"),
("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"),
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"}), "Convert selected keyframes to Auto Bezier"),
("Easy Ease", "keys.easyEase", json!({}), "Easy Ease (F9)"),
("Ease In", "keys.easyEaseIn", json!({}), "Easy Ease In (Shift+F9)"),
("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"),
] {
let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or("");
if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) {
actions.push((cmd.into(), params));
}
}
buttons.more(app, ui, p, bar);
}
/// 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;
let mut t1 = f64::NEG_INFINITY;
let mut v0 = f64::INFINITY;
let mut v1 = f64::NEG_INFINITY;
for c in cs {
for k in c.keys.iter().filter(|k| k.sel) {
for (t, v) in std::iter::once((k.t, k.v)).chain(k.handles.iter().map(|h| (h.t, h.v))) {
t0 = t0.min(t);
t1 = t1.max(t);
v0 = v0.min(v);
v1 = v1.max(v);
}
}
}
if !t0.is_finite() {
return;
}
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);
app.ui.timeline.start = (t0 - span * 0.1).max(0.0);
let pad = ((v1 - v0) * 0.15).max(1.0);
app.ui.timeline.graph_auto_zoom = false;
app.ui.timeline.graph_range = Some((v0 - pad, v1 + pad));
let _ = tm;
}
fn nice_step(raw: f64) -> f64 {
let raw = raw.abs().max(1e-9);
let mag = 10f64.powf(raw.log10().floor());
let n = raw / mag;
mag * if n < 1.5 {
1.0
} else if n < 3.5 {
2.0
} else if n < 7.5 {
5.0
} else {
10.0
}
}
fn fmt(v: f64, step: f64) -> String {
let dec = if step >= 1.0 { 0 } else { (-step.log10()).ceil() as usize };
let s = format!("{v:.dec$}");
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s }
}
/// A Graph Editor key drag: where the button went down and the grabbed key's comp time then.
#[derive(Clone, Copy, Debug)]
struct KeyGrab {
press: Pos2,
t: f64,
}
fn key_grab_id() -> egui::Id {
egui::Id::new("graph-key-grab")
}
/// A Bezier handle moving with a handle drag: its key, side and dimension, the key's place
/// (comp seconds, graph value) and the handle's end when the drag began.
#[derive(Clone, Debug)]
struct HandleGrab {
layer: LayerId,
prop: u64,
/// `None` for a property drawn as one curve (spatial).
dim: Option<usize>,
time: Tick,
out: bool,
key: (f64, f64),
end: (f64, f64),
/// The speed was negative (the speed graph shows its magnitude).
negative: bool,
/// Segment duration (layer seconds) the influence is relative to.
dur: f64,
}
impl HandleGrab {
/// `keys.setEase` putting the handle's end at (`t` comp seconds, `v` in the graph).
fn ease(&self, t: f64, v: f64, speed: bool, merge: &str) -> serde_json::Value {
let dir = if self.out { 1.0 } else { -1.0 };
let dt = ((t - self.key.0) * dir).max(self.dur * 0.001);
let influence = (dt / self.dur.max(1e-9) * 100.0).clamp(0.1, 100.0);
let sp = if speed {
let mag = v.max(0.0);
if self.negative { -mag } else { mag }
} else {
(v - self.key.1) / dt * dir
};
let side = if self.out { "out" } else { "in" };
let mut params =
json!({"layer": self.layer.0, "prop": self.prop, "time": self.time.seconds(), "side": side, "speed": sp, "influence": influence, "merge": merge});
if let Some(d) = self.dim {
params["dim"] = json!(d);
}
params
}
}
/// A handle drag: where the button went down, and the handles that move.
type HandleDrag = (Pos2, Vec<HandleGrab>);
fn handle_drag_id() -> egui::Id {
egui::Id::new("graph-handle-drag")
}
/// Which axes a drag may move on (time, value): both, or with Shift only the one the pointer moved more along.
fn shift_axes(shift: bool, dx: f32, dy: f32) -> (bool, bool) {
if shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) }
}
#[cfg(test)]
mod tests {
use super::shift_axes;
#[test]
fn shift_locks_drag_to_dominant_axis() {
assert_eq!(shift_axes(false, 10.0, 3.0), (true, true));
assert_eq!(shift_axes(true, 10.0, -3.0), (true, false));
assert_eq!(shift_axes(true, 2.0, -9.0), (false, true));
assert_eq!(shift_axes(true, 5.0, 5.0), (true, false));
}
}