Merge pull request #1109 from eliasx45/upstream/azerty-graph-zoom

# Conflicts:
#	crates/engine/src/commands/time.rs
#	crates/ui-egui/src/panels/timeline.rs
This commit is contained in:
Brandon Thomas
2026-10-13 04:29:47 -04:00
11 changed files with 530 additions and 37 deletions
+3 -2
View File
@@ -382,8 +382,9 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) };
let extra = modifiers(p);
m |= extra;
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m });
let physical_key = name.starts_with("Physical+").then_some(key);
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: false, repeat: false, modifiers: m });
Outcome::AfterInput
}
"ui.type" => {
+34 -12
View File
@@ -672,7 +672,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);
}
@@ -1636,6 +1636,7 @@ pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> {
m.ctrl = true;
}
}
"Physical" => {}
"Shift" => m.shift = true,
"Alt" => m.alt = true,
"Ctrl" => m.ctrl = true,
@@ -1683,6 +1684,9 @@ pub type Binding = (egui::Modifiers, egui::Key, String, Value);
pub fn bindings(session: &effectcraft_engine::Session) -> Vec<Binding> {
let mut v: Vec<Binding> = Vec::new();
for (sc, b) in session.shortcuts().bindings() {
if sc.starts_with("Physical+") {
continue;
}
// Off macOS `Cmd` is Ctrl, so a custom Ctrl+Cmd shortcut would
// steal the plain Cmd one: such macOS-only shortcuts aren't bound there.
if !cfg!(target_os = "macos") && sc.split('+').any(|p| p == "Ctrl") && sc.split('+').any(|p| p == "Cmd") {
@@ -1800,7 +1804,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
// The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer.
let pasted =
if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None };
let mut events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| {
let mut events: Vec<(egui::Key, egui::Modifiers, bool, Option<egui::Key>)> = ctx.input(|i| {
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
// Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and
@@ -1816,31 +1820,31 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
egui::Event::ModifiersChanged(m) => at = Some(*m),
// egui forgets the modifiers held when the window loses focus.
egui::Event::WindowFocused(false) => at = Some(egui::Modifiers::NONE),
egui::Event::Key { key, pressed: true, modifiers, repeat, .. }
egui::Event::Key { key, physical_key, pressed: true, modifiers, repeat, .. }
if !*repeat
|| matches!(
key,
egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown
) =>
{
out.push((*key, *modifiers, true));
out.push((*key, *modifiers, true, *physical_key));
}
egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => out.push((egui::Key::Space, egui::Modifiers::NONE, false)),
egui::Event::Copy if clipboard => out.push((egui::Key::C, held(at), true)),
egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => out.push((egui::Key::Space, egui::Modifiers::NONE, false, None)),
egui::Event::Copy if clipboard => out.push((egui::Key::C, held(at), true, None)),
// On Windows, Shift+Delete is the old Cut key and arrives as Cut too. The Project
// panel has no clipboard: there a Cut with only Shift held is Shift+Delete (Delete
// Project Items Without Confirmation). Never when the modifiers aren't known.
egui::Event::Cut if clipboard && project && at.is_some_and(only_shift) => {
out.push((egui::Key::Delete, egui::Modifiers::SHIFT, true));
out.push((egui::Key::Delete, egui::Modifiers::SHIFT, true, None));
}
egui::Event::Cut if clipboard => out.push((egui::Key::X, held(at), true)),
egui::Event::Paste(_) if clipboard => out.push((egui::Key::V, held(at), true)),
egui::Event::Cut if clipboard => out.push((egui::Key::X, held(at), true, None)),
egui::Event::Paste(_) if clipboard => out.push((egui::Key::V, held(at), true, None)),
_ => {}
}
}
out
});
events.retain(|(key, _, _)| (*key == egui::Key::Tab) == tab_only);
events.retain(|(key, _, _, _)| (*key == egui::Key::Tab) == tab_only);
// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it
// comes as text.
if !tab_only && clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) {
@@ -1860,7 +1864,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
return;
}
let binds = bindings(&app.session);
for (key, mods, pressed) in events {
for (key, mods, pressed, physical_key) in events {
// Plain Spacebar runs its shortcut (Play Current Preview) on the release: held, it is
// the Hand tool (After Effects), and a drag with it must not start or stop playback.
if key == egui::Key::Space && (!pressed || !mods.any()) {
@@ -1919,7 +1923,25 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
let _ = invoke(app, ctx, "edit.clear", json!({}));
continue;
}
if let Some((_, _, id, params)) = binds.iter().find(|(m, k, ..)| *k == key && mods_match(*m, mods)) {
// Explicit physical bindings distinguish keys that a layout reports with the same
// logical name (for example AZERTY's minus and right parenthesis).
let physical = physical_key.and_then(|key| {
app.session.shortcuts().bindings().into_iter().find_map(|(sc, b)| {
let (m, k) = parse_shortcut(sc.strip_prefix("Physical+")?)?;
(k == key && mods_match(m, mods)).then(|| (b.command.clone(), b.params.clone()))
})
});
if let Some((id, params)) = physical {
if let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) {
app.ui.status = e;
}
continue;
}
if let Some((_, _, id, params)) = binds.iter().filter(|(m, k, ..)| *k == key && mods_match(*m, mods)).min_by_key(|(_, _, id, params)| {
// The focused panel wins over an application command sharing its chord.
let key = effectcraft_engine::shortcuts::binding_key(id, params);
app.session.shortcuts().find(&key).is_none_or(|b| b.scope != app.ui.focused.id())
}) {
let (id, params) = (id.clone(), if params.is_null() { json!({}) } else { params.clone() });
if id == "edit.paste"
&& let Some(t) = &pasted
+68 -8
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);
@@ -561,8 +581,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);
@@ -602,6 +621,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;
@@ -621,10 +664,27 @@ 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);
app.ui.timeline.start = (t0 - span * 0.1).max(0.0);
let pps = plot.width() as f64 * 0.8 / span;
// Through the timeline's zoom, so the fit never zooms out past the whole comp.
if let Some(comp) = app.session.active_comp_arc() {
let fit = super::timeline::fit_pps(plot.width(), &comp);
super::timeline::set_zoom(&mut app.ui.timeline, &comp, pps, fit, (t0 - span * 0.1).max(0.0), 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));
+44 -13
View File
@@ -521,15 +521,18 @@ 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 {
pub(crate) fn fit_pps(width: f32, comp: &Comp) -> f64 {
(width - 10.0).max(1.0) as f64 / comp.duration.seconds().max(0.01)
}
/// Zoom the time ruler to `pps` pixels per second, keeping comp time `at_t` at `at_px` pixels
/// from the ruler's start. Zooming out stops at `fit`, where the whole comp shows (and the
/// ruler keeps fitting it).
fn set_zoom(tl: &mut crate::state::TimelineState, comp: &Comp, pps: f64, fit: f64, at_t: f64, at_px: f64) {
pub(crate) fn set_zoom(tl: &mut crate::state::TimelineState, comp: &Comp, pps: f64, fit: f64, at_t: f64, at_px: f64) {
let npps = pps.min(MAX_PPS.max(fit)).max(fit);
if npps <= fit {
tl.pps = None;
@@ -1414,6 +1417,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0);
app.ui.timeline.start = centered_on(span, comp.duration.seconds(), app.session.time().seconds());
}
// As in After Effects, the ruler never scrolls past the comp's end: zoomed in, the last
// visible time is at most the comp duration.
let visible_secs = ((graph_x1 - graph_x0 - 6.0).max(1.0) as f64 / pps.max(1e-6)).max(0.0);
let max_start = (comp.duration.seconds() - visible_secs).max(0.0);
app.ui.timeline.start = app.ui.timeline.start.clamp(0.0, max_start);
let tm = TMap { x0: graph_x0 + 6.0, start: app.ui.timeline.start, pps };
ctx.data_mut(|d| d.insert_temp(tl_map_id(), (tm.x0, tm.start, tm.pps)));
let top = rect.min.y;
@@ -1700,6 +1708,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tt = Tick::from_seconds_f64(secs);
app.session.set_time(tt);
app.stop();
// Dragged past either edge of the ruler (over the layer outline, say), the view scrolls so
// the current time indicator stays in sight (After Effects).
if app.ui.timeline.pps.is_some() {
let now = app.session.time().seconds();
let span = tm.t(bin.min.x) - tm.start;
if now < tm.start {
app.ui.timeline.start = now.max(0.0);
} else if now > tm.start + span {
app.ui.timeline.start = (now - span).max(0.0);
}
}
// Ctrl/Cmd-drag scrubs the audio too (After Effects).
if rresp.dragged()
&& ui.input(|i| i.modifiers.command)
@@ -1877,6 +1896,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));
@@ -3183,24 +3203,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.