diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 630bfc2d..3d4ab366 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -151,6 +151,7 @@ pub fn normalize(s: &str) -> Option { } let (mut ctrl, mut cmd, mut alt, mut shift) = (false, false, false, false); let mut key: Option = 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 { } 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 { 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); diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index e409fc66..2710365e 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -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}"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 113c8bc0..bb45d110 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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" => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 40ad885f..04f55a02 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { let mut v: Vec = 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)> = 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 diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 0ba5d2d1..fc6d40e8 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -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::(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::(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::(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)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 53799a0e..535a8dc2 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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 = 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. diff --git a/crates/ui-egui/tests/ui_azerty_shortcuts.rs b/crates/ui-egui/tests/ui_azerty_shortcuts.rs new file mode 100644 index 00000000..dde4b190 --- /dev/null +++ b/crates/ui-egui/tests/ui_azerty_shortcuts.rs @@ -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()); +} diff --git a/crates/ui-egui/tests/ui_graph_fit.rs b/crates/ui-egui/tests/ui_graph_fit.rs new file mode 100644 index 00000000..c161dfef --- /dev/null +++ b/crates/ui-egui/tests/ui_graph_fit.rs @@ -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); +} diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index e5b2cae9..8b22d3f2 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -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(); diff --git a/crates/ui-egui/tests/ui_time_zoom_controls.rs b/crates/ui-egui/tests/ui_time_zoom_controls.rs new file mode 100644 index 00000000..4ab725d6 --- /dev/null +++ b/crates/ui-egui/tests/ui_time_zoom_controls.rs @@ -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::().unwrap(), b.parse::().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 = 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}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c9b86777..5f7a3b51 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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+` 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 |