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 committed 2026-10-13 04:29:47 -04:00
commit 287c35dae7
11 files changed
+530 -37

No files matched your search

+3 -1
View File
@@ -151,6 +151,7 @@ pub fn normalize(s: &str) -> Option<String> {
}
let (mut ctrl, mut cmd, mut alt, mut shift) = (false, false, false, false);
let mut key: Option<String> = None;
let mut physical = false;
// `Cmd++` / `+` name the plus key.
let (head, plus) = match s.strip_suffix("++") {
Some(h) => (h, true),
@@ -177,6 +178,7 @@ pub fn normalize(s: &str) -> Option<String> {
}
for p in parts {
match p.to_ascii_lowercase().as_str() {
"physical" => physical = true,
"cmd" | "command" | "meta" | "super" | "win" => cmd = true,
"ctrl" | "control" => ctrl = true,
"alt" | "opt" | "option" => alt = true,
@@ -188,7 +190,7 @@ pub fn normalize(s: &str) -> Option<String> {
key = Some("+".into());
}
let key = key?;
let mut out = String::new();
let mut out = if physical { "Physical+".to_string() } else { String::new() };
for (on, m) in [(ctrl, "Ctrl+"), (cmd, "Cmd+"), (alt, "Alt+"), (shift, "Shift+")] {
if on {
out.push_str(m);
+10
View File
@@ -436,3 +436,13 @@ fn numeric_string_resolves_as_id() {
let r = s.execute("layer.tree", json!({"layer": l.to_string(), "comp": cid.0.to_string()})).unwrap();
assert_eq!(r["id"].as_u64(), Some(l), "{r}");
}
/// Go to Work Area Start / End have After Effects' default keys (Shift+Home / Shift+End).
#[test]
fn work_area_navigation_has_after_effects_default_keys() {
let s = Session::default();
for (id, key) in [("time.workAreaStart", "Shift+Home"), ("time.workAreaEnd", "Shift+End")] {
let b = s.shortcuts().find(id).unwrap_or_else(|| panic!("{id} is bindable"));
assert_eq!(b.defaults, [key.to_string()], "{id}");
}
}
+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.
@@ -0,0 +1,82 @@
//! Layout collisions must not dispatch both the physical and logical shortcuts.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Key, Modifiers, vec2};
use egui_kittest::Harness;
use serde_json::json;
#[test]
fn physical_shortcuts_distinguish_azerty_zoom_keys() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 60})).unwrap();
let mut app = EffectcraftApp::new(s);
app.ui.timeline.pps = Some(100.0);
for (command, keys) in [("timeline.zoomIn", "Physical+6"), ("timeline.zoomOut", "Physical+Minus")] {
app.session.execute("shortcuts.set", json!({"command": command, "keys": keys})).unwrap();
}
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app);
h.run_steps(3);
h.state_mut().ui.timeline.pps = Some(100.0);
for (physical, expected) in [(Key::Num6, 150.0), (Key::Minus, 100.0)] {
h.event(Event::Key { key: Key::Minus, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
h.event(Event::Key { key: Key::Minus, physical_key: Some(physical), pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.step();
assert!(
(h.state().ui.timeline.pps.unwrap() - expected).abs() < 0.01,
"physical={physical:?} expected={expected} actual={:?}",
h.state().ui.timeline.pps
);
}
}
#[test]
fn work_area_navigation_uses_last_included_frame() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 10, "frameRate": 30})).unwrap();
s.execute("comp.workArea", json!({"start": 2.0, "end": 5.0})).unwrap();
assert_eq!(s.execute("time.workAreaStart", json!({})).unwrap()["frame"], 60);
assert_eq!(s.execute("time.workAreaEnd", json!({})).unwrap()["frame"], 149);
}
#[test]
fn shifted_one_toggles_only_selected_video_and_iso_key_maximizes() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
s.execute("layer.newSolid", json!({"name": "Unselected"})).unwrap();
let selected = s.execute("layer.newSolid", json!({"name": "Selected"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [selected]})).unwrap();
let mut app = EffectcraftApp::new(s);
app.session.execute("shortcuts.set", json!({"command": "layer.setSwitch", "params": {"switch": "video"}, "keys": "Shift+1"})).unwrap();
app.session.execute("shortcuts.set", json!({"command": "window.maximizePanel", "keys": "IntlBackslash"})).unwrap();
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app);
h.run_steps(3);
for visible in [false, true] {
h.event(Event::Key { key: Key::Num1, physical_key: Some(Key::Num1), pressed: true, repeat: false, modifiers: Modifiers::SHIFT });
h.step();
h.event(Event::Key { key: Key::Num1, physical_key: Some(Key::Num1), pressed: false, repeat: false, modifiers: Modifiers::SHIFT });
h.step();
for layer in &h.state().session.active_comp().unwrap().layers {
assert_eq!(layer.switches.video, layer.id.0 != selected || visible);
}
}
h.event(Event::Key { key: Key::IntlBackslash, physical_key: Some(Key::IntlBackslash), pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
assert!(h.state().ui.maximized.is_some());
}
#[test]
fn timeline_shift_p_reveals_instead_of_adding_a_text_animator() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box"})).unwrap();
let mut app = EffectcraftApp::new(s);
app.session.execute("shortcuts.set", json!({"command": "layer.addTextAnimator", "params": {"properties": ["position"]}, "keys": "Shift+P"})).unwrap();
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app);
h.run_steps(3);
h.state_mut().ui.focused = effectcraft_ui_egui::dock::PanelKind::Timeline;
h.event(Event::Key { key: Key::P, physical_key: Some(Key::P), pressed: true, repeat: false, modifiers: Modifiers::SHIFT });
h.step();
assert!(h.state().ui.timeline.reveal.contains(&"position".to_string()));
assert!(h.state().ui.status.is_empty());
}
+79
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);
}
+3 -1
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();
@@ -0,0 +1,197 @@
//! Time zoom in the Timeline driven over the control channel, as an agent (or a person) uses it:
//! the `=` / `-` keys, the zoom-out / zoom-in buttons and the slider in the footer, and the
//! time navigator's ends. The layer bars and the Graph Editor share one time zoom (After
//! Effects): the same controls zoom the graph's time axis, centred on the current time, and
//! switching between the views keeps it.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::control::{self, ControlRequest, Outcome};
use egui_kittest::Harness;
use serde_json::{Value, json};
/// A 10 s, 30 fps comp with a layer whose Opacity is keyed at 1 s and 8 s, revealed, the CTI at
/// 4 s; the Graph Editor on when `graph`.
fn harness(graph: bool) -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Zoom", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).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 [(1.0, 0.0), (8.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
s.execute("layer.select", json!({"layers": [id.0]})).unwrap();
let mut app = EffectcraftApp::new(s);
// (Opening a comp may zoom; start from the whole comp.)
app.ui.timeline.pps = None;
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(3);
h.state_mut().session.execute("time.set", json!({"time": 4.0})).unwrap();
h.state_mut().ui.timeline.pps = None;
send(&mut h, "ui.menu.invoke", json!({"command": "timeline.reveal.opacity"}));
if graph {
send(&mut h, "ui.click", json!({"id": "timeline.switch.graph"}));
}
h.run_steps(3);
assert_eq!(h.state().ui.timeline.graph_editor, graph);
h
}
/// One control-channel request, then its synthetic input fed in one event per frame (kittest
/// doesn't call `raw_input_hook`).
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
if let Outcome::Done(v) = control::handle(h.state_mut(), &ctx, &req) {
assert!(v["ok"] != false, "{method}: {v}");
}
for e in h.state_mut().take_synthetic_input() {
h.event(e);
h.step();
}
h.run_steps(3);
}
/// Visible span (start, seconds shown) from the ruler's automation label ("start,pps"); the
/// time graph ends 18 px before the ruler and maps time from 6 px in.
fn view(h: &Harness<'_, EffectcraftApp>) -> (f64, f64) {
let e = h.state().auto.find("timeline.ruler").expect("ruler");
let (start, pps) = e.label.split_once(',').map(|(a, b)| (a.parse::<f64>().unwrap(), b.parse::<f64>().unwrap())).unwrap();
(start, (e.rect[2] - 24.0) as f64 / pps)
}
fn x_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> f32 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
e.rect[0] + e.rect[2] / 2.0
}
/// The span shrinks (`in`) or grows by more than 10 %, and the CTI stays where it was.
fn zoomed(h: &Harness<'_, EffectcraftApp>, before: f64, cti: f32, zoom_in: bool, what: &str) -> f64 {
let (_, span) = view(h);
if zoom_in {
assert!(span < before * 0.9, "{what} zooms in: {before} -> {span}");
} else {
assert!(span > before * 1.1, "{what} zooms out: {before} -> {span}");
}
assert!((x_of(h, "timeline.cti") - cti).abs() < 1.5, "{what}: the CTI stays put ({cti} -> {})", x_of(h, "timeline.cti"));
span
}
fn check_controls(graph: bool) {
let mut h = harness(graph);
let (_, span) = view(&h);
assert!((span - 10.0).abs() < 0.2, "starts at the whole comp: {span}");
let cti = x_of(&h, "timeline.cti");
send(&mut h, "ui.key", json!({"key": "="}));
let span = zoomed(&h, span, cti, true, "=");
send(&mut h, "ui.key", json!({"key": "="}));
let span = zoomed(&h, span, cti, true, "= again");
send(&mut h, "ui.key", json!({"key": "-"}));
let span = zoomed(&h, span, cti, false, "-");
send(&mut h, "ui.click", json!({"id": "timeline.zoomInButton"}));
let span = zoomed(&h, span, cti, true, "the zoom-in button");
send(&mut h, "ui.click", json!({"id": "timeline.zoomOutButton"}));
let span = zoomed(&h, span, cti, false, "the zoom-out button");
// A click on the slider's track jumps the zoom there.
send(&mut h, "ui.click", json!({"id": "timeline.zoomSlider", "fx": 0.9}));
zoomed(&h, span, cti, true, "a click near the slider's right end");
send(&mut h, "ui.click", json!({"id": "timeline.zoomSlider", "fx": 0.0}));
assert_eq!(h.state().ui.timeline.pps, None, "the slider's left end shows the whole comp");
// Zooming out stops at the whole comp.
send(&mut h, "ui.click", json!({"id": "timeline.zoomOutButton"}));
send(&mut h, "ui.key", json!({"key": "-"}));
assert_eq!(h.state().ui.timeline.pps, None, "no further out than the whole comp");
}
#[test]
fn keys_buttons_and_slider_zoom_the_layer_bars() {
check_controls(false);
}
#[test]
fn keys_buttons_and_slider_zoom_the_graph_editor_time_axis() {
check_controls(true);
}
/// In the Graph Editor the curves follow the time zoom: a key's screen distance from the CTI
/// grows with `=`, and the navigator's end handle zooms the graph too.
#[test]
fn graph_curves_follow_the_time_zoom_and_the_navigator() {
let mut h = harness(true);
let key_x = |h: &Harness<'_, EffectcraftApp>| {
let mut ks: Vec<f32> = h.state().auto.query("timeline.graph.key.").iter().map(|e| e.rect[0] + e.rect[2] / 2.0).collect();
ks.sort_by(f32::total_cmp);
ks
};
let ks = key_x(&h);
assert_eq!(ks.len(), 2, "both Opacity keys in the graph: {ks:?}");
let cti = x_of(&h, "timeline.cti");
let d0 = ks[1] - cti;
send(&mut h, "ui.key", json!({"key": "="}));
let d1 = key_x(&h).last().copied().unwrap_or(cti) - cti;
assert!(d1 > d0 * 1.3, "the 8 s key moves away from the CTI as time zooms in: {d0} -> {d1}");
// Back to the whole comp, then the navigator's end handle dragged to the middle: 0..5 s.
send(&mut h, "ui.menu.invoke", json!({"command": "timeline.zoomFit"}));
let nav = h.state().auto.find("timeline.navigator").unwrap().rect;
let end = h.state().auto.find("timeline.navigator.end").unwrap().rect;
let y = end[1] + end[3] / 2.0;
send(&mut h, "ui.drag", json!({"from": {"x": end[0] + end[2] / 2.0, "y": y}, "to": {"x": nav[0] + nav[2] * 0.5, "y": y}}));
let (start, span) = view(&h);
assert!(start.abs() < 1e-6 && (span - 5.0).abs() < 0.3, "0..5 s: {start} + {span}");
assert_eq!(key_x(&h).len(), 1, "the 8 s key is past the visible span");
}
#[test]
fn leaving_the_graph_keeps_its_time_zoom() {
let mut h = harness(false);
// Opening the Graph Editor fits the graphs (ui_graph_fit); the zoom set there stays.
send(&mut h, "ui.click", json!({"id": "timeline.switch.graph"}));
assert!(h.state().ui.timeline.graph_editor);
send(&mut h, "ui.key", json!({"key": "="}));
let graph = view(&h);
send(&mut h, "ui.click", json!({"id": "timeline.switch.graph"}));
assert!(!h.state().ui.timeline.graph_editor);
assert_eq!(view(&h), graph);
}
#[test]
fn azerty_physical_zoom_keys_zoom_the_graph() {
let mut h = harness(true);
// The "Elias AE"-style bindings: AZERTY's - (US 6 position) zooms in, ) (US minus) out.
for (command, keys) in [("timeline.zoomIn", "Physical+6"), ("timeline.zoomOut", "Physical+Minus")] {
h.state_mut().session.execute("shortcuts.set", json!({"command": command, "keys": keys})).unwrap();
}
let (_, span0) = view(&h);
send(&mut h, "ui.key", json!({"key": "Physical+6"}));
let (_, span1) = view(&h);
assert!(span1 < span0 * 0.8, "Physical+6 zooms the graph in: {span0} -> {span1}");
send(&mut h, "ui.key", json!({"key": "Physical+Minus"}));
let (_, span2) = view(&h);
assert!(span2 > span1 * 1.2, "Physical+Minus zooms the graph out: {span1} -> {span2}");
}
#[test]
fn zoomed_in_the_ruler_stops_at_the_comp_end_and_a_scrub_past_the_edge_scrolls() {
let mut h = harness(false);
for _ in 0..4 {
send(&mut h, "ui.key", json!({"key": "="}));
}
// Scrolled as far right as it goes: the view ends at the comp's end, not past it (AE).
h.state_mut().ui.timeline.start = 9.9;
h.run_steps(3);
let (start, span) = view(&h);
assert!(span < 9.0, "zoomed in: {span}");
assert!((start + span - 10.0).abs() < 0.05, "the view ends at 10 s: {start} + {span}");
// A click at the ruler's right end puts the CTI there.
let ruler = h.state().auto.find("timeline.ruler").unwrap().rect;
let y = ruler[1] + ruler[3] - 4.0;
send(&mut h, "ui.click", json!({"x": ruler[0] + ruler[2] - 26.0, "y": y}));
let t = h.state().session.time().seconds();
assert!(t > start + span * 0.9, "the CTI jumps to the click near the end: {t}");
// Dragged from the ruler out over the layer outline, the view scrolls with the CTI.
send(&mut h, "ui.drag", json!({"from": {"x": ruler[0] + 40.0, "y": y}, "to": {"x": ruler[0] - 200.0, "y": y}, "steps": 20}));
let (start2, span2) = view(&h);
let t2 = h.state().session.time().seconds();
assert!(t2 < start, "the CTI moved left of the old view: {t2} vs {start}");
assert!(t2 >= start2 - 1e-6 && t2 <= start2 + span2, "the CTI stays in view: {t2} in {start2}+{span2}");
}
+7
View File
@@ -106,6 +106,13 @@ Points are logical window coordinates. A target is `{id}` (the element's centre;
| `ui.key` | `{key}`, for example `Space`, `F9`, `Cmd+Z`, `Cmd+Shift+Z`, `PageDown`, `Escape` |
| `ui.type` | `{text}`: text input into the focused field |
Shortcut presets also accept `Physical+<shortcut>` for a keyboard position, using egui's
US-position names (for example `Physical+6` and `Physical+Minus` distinguish AZERTY's
`-` and `)` keys). Physical bindings take precedence over logical bindings for that key
press. `ui.key` accepts the same notation to test them. Ordinary shortcuts remain layout-aware.
`time.workAreaStart` and `time.workAreaEnd` navigate to the first and last included work-area
frames and can be assigned through `shortcuts.set`.
## Locating things
| Method | Params | Result |