//! Menus, keyboard shortcuts and UI-level commands. The menu bar is the engine's After Effects //! menu tree (`effectcraft_engine::menus`): every entry is an engine command, and frontend-only //! commands (viewer zoom, panels, dialogs…) come back as `Event::Frontend` and are performed by //! [`frontend`]. [`UI_COMMANDS`] holds the remaining UI-only shortcuts (tools, timeline //! navigation, reveal keys). `invoke` is the single entry point used by menus, shortcuts and the //! control channel. use serde_json::{Value, json}; use crate::EffectcraftApp; use crate::dock::PanelKind; use crate::state::{Resolution, Tool}; use effectcraft_engine::menus::{MenuEntry, MenuNode}; pub struct UiCommand { pub id: &'static str, pub label: &'static str, pub menu: &'static [&'static str], pub shortcut: Option<&'static str>, } macro_rules! uic { ($id:literal, $label:literal, [$($m:literal),*], $sc:expr) => { UiCommand { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc } }; } /// UI-only commands (not in the AE menus; shortcuts and the command palette). pub const UI_COMMANDS: &[UiCommand] = &[ uic!("window.dockAction", "Docking Action", [], None), uic!("playback.ramPreview", "Preview (Numpad 0)", [], Some("Num0")), uic!("playback.preview.shiftSpacebar", "Preview (Shift+Spacebar)", [], Some("Shift+Space")), uic!("playback.preview.shiftNumpad0", "Preview (Shift+Numpad 0)", [], Some("Shift+Num0")), uic!("playback.preview.altNumpad0", "Preview (Alt+Numpad 0)", [], Some("Alt+Num0")), uic!("playback.stop", "Stop", [], None), uic!("view.fit", "Fit", [], Some("Shift+/")), uic!("view.actualSize", "100%", [], Some("/")), uic!("view.fitUpTo100", "Fit up to 100%", [], Some("Alt+/")), uic!("view.nextItem", "Next Composition in Viewer", [], Some("Shift+.")), uic!("view.previousItem", "Previous Composition in Viewer", [], Some("Shift+,")), uic!("viewer.nudge.up", "Move Selected Layers Up 1 Pixel at Current Magnification", [], Some("ArrowUp")), uic!("viewer.nudge.down", "Move Selected Layers Down 1 Pixel at Current Magnification", [], Some("ArrowDown")), uic!("viewer.nudge.left", "Move Selected Layers Left 1 Pixel at Current Magnification", [], Some("ArrowLeft")), uic!("viewer.nudge.right", "Move Selected Layers Right 1 Pixel at Current Magnification", [], Some("ArrowRight")), uic!("viewer.nudge.up10", "Move Selected Layers Up 10 Pixels at Current Magnification", [], Some("Shift+ArrowUp")), uic!("viewer.nudge.down10", "Move Selected Layers Down 10 Pixels at Current Magnification", [], Some("Shift+ArrowDown")), uic!("viewer.nudge.left10", "Move Selected Layers Left 10 Pixels at Current Magnification", [], Some("Shift+ArrowLeft")), uic!("viewer.nudge.right10", "Move Selected Layers Right 10 Pixels at Current Magnification", [], Some("Shift+ArrowRight")), uic!("view.safeMargins", "Title/Action Safe", [], Some("'")), uic!("view.transparencyGrid", "Transparency Grid", [], None), uic!("view.fastPreviews", "Fast Previews", [], None), uic!("view.theme.toggle", "Next Appearance Mode", [], None), uic!("view.theme.dark", "Theme: Dark", [], None), uic!("view.theme.darker", "Theme: Darker", [], None), uic!("view.theme.light", "Theme: Light", [], None), uic!("timeline.zoomIn", "Zoom In Time", [], Some("=")), uic!("timeline.zoomOut", "Zoom Out Time", [], Some("-")), uic!("timeline.zoomFit", "Zoom to Fit Comp", [], Some("Shift+;")), uic!("timeline.scrollToTime", "Scroll to Current Time", [], Some("D")), uic!("timeline.scrollToLayer", "Scroll Topmost Selected Layer to Top", [], Some("X")), uic!("timeline.parentColumn", "Show/Hide Parent Column", [], Some("Shift+F4")), uic!("timeline.zoomFrameToggle", "Zoom In to Frame Level / Out to the Whole Comp", [], Some(";")), uic!("timeline.graphEditor", "Graph Editor", [], Some("Shift+F3")), uic!("timeline.switchesModes", "Toggle Switches / Modes", [], Some("F4")), uic!("timeline.workAreaBegin", "Set Work Area Begin", [], Some("B")), uic!("timeline.workAreaEnd", "Set Work Area End", [], Some("N")), uic!("timeline.moveInToTime", "Move Layer In Point to Current Time", [], Some("[")), uic!("timeline.moveOutToTime", "Move Layer Out Point to Current Time", [], Some("]")), uic!("timeline.trimInToTime", "Trim Layer In Point to Current Time", [], Some("Alt+[")), uic!("timeline.trimOutToTime", "Trim Layer Out Point to Current Time", [], Some("Alt+]")), uic!("timeline.reveal.position", "Reveal Position", [], Some("P")), uic!("timeline.reveal.scale", "Reveal Scale", [], Some("S")), uic!("timeline.reveal.rotation", "Reveal Rotation", [], Some("R")), uic!("timeline.reveal.opacity", "Reveal Opacity", [], Some("T")), uic!("timeline.reveal.anchor", "Reveal Anchor Point", [], Some("A")), uic!("timeline.reveal.effects", "Reveal Effects", [], Some("E")), uic!("timeline.reveal.masks", "Reveal Masks", [], Some("M")), uic!("timeline.reveal.feather", "Reveal Mask Feather", [], Some("F")), uic!("timeline.reveal.levels", "Reveal Audio Levels (press twice: Waveform)", [], Some("L")), uic!("timeline.reveal.timeRemap", "Reveal Time Remap", [], None), uic!("timeline.reveal.expressions", "Reveal Expressions", [], None), uic!("timeline.reveal.maskOpacity", "Reveal Mask Opacity", [], None), uic!("timeline.reveal.maskPath", "Reveal Mask Path", [], None), uic!("timeline.reveal.material", "Reveal Material Options", [], None), uic!("timeline.reveal.paint", "Reveal Paint, Roto Brush and Puppet", [], None), uic!("timeline.reveal.selected", "Reveal Selected Properties", [], None), uic!("timeline.reveal.missingEffects", "Reveal Missing Effects", [], None), uic!("timeline.reveal.waveform", "Reveal Audio Waveform", [], None), uic!("timeline.revealAdd.position", "Add Position to Revealed Properties", [], Some("Shift+P")), uic!("timeline.revealAdd.scale", "Add Scale to Revealed Properties", [], Some("Shift+S")), uic!("timeline.revealAdd.rotation", "Add Rotation to Revealed Properties", [], Some("Shift+R")), uic!("timeline.revealAdd.opacity", "Add Opacity to Revealed Properties", [], Some("Shift+T")), uic!("timeline.revealAdd.anchor", "Add Anchor Point to Revealed Properties", [], Some("Shift+A")), uic!("timeline.revealAdd.effects", "Add Effects to Revealed Properties", [], Some("Shift+E")), uic!("timeline.revealAdd.masks", "Add Masks to Revealed Properties", [], Some("Shift+M")), uic!("timeline.revealAdd.feather", "Add Mask Feather to Revealed Properties", [], Some("Shift+F")), uic!("timeline.revealAdd.levels", "Add Audio Levels to Revealed Properties", [], Some("Shift+L")), uic!("timeline.revealAdd.animated", "Add Animated Properties to Revealed Properties", [], Some("Shift+U")), uic!("timeline.column", "Show/Hide Timeline Column", [], None), uic!("timeline.sourceName", "Source Name / Layer Name", [], None), uic!("timeline.search", "Search Timeline", [], None), uic!("flowchart.options", "Flowchart Options", [], None), uic!("flowchart.graph", "Flowchart Graph", [], None), uic!("timeline.collapseAll", "Collapse All", [], None), uic!("timeline.twirlSelected", "Twirl Selected Layers Open / Closed", [], Some("Cmd+`")), uic!("timeline.keyAt.anchor", "Add or Remove Anchor Point Keyframe", [], Some("Alt+Shift+A")), uic!("timeline.keyAt.position", "Add or Remove Position Keyframe", [], Some("Alt+Shift+P")), uic!("timeline.keyAt.scale", "Add or Remove Scale Keyframe", [], Some("Alt+Shift+S")), uic!("timeline.keyAt.rotation", "Add or Remove Rotation Keyframe", [], Some("Alt+Shift+R")), uic!("timeline.keyAt.opacity", "Add or Remove Opacity Keyframe", [], Some("Alt+Shift+T")), uic!("tool.selection", "Selection Tool", [], Some("V")), uic!("tool.hand", "Hand Tool", [], Some("H")), uic!("tool.zoom", "Zoom Tool", [], Some("Z")), uic!("tool.orbit", "Orbit Tool", [], Some("1")), uic!("tool.panCamera", "Pan Camera Tool", [], Some("2")), uic!("tool.dolly", "Dolly Tool", [], Some("3")), uic!("tool.camera", "Camera Tools", [], Some("C")), uic!("tool.rotate", "Rotation Tool", [], Some("W")), uic!("tool.panBehind", "Pan Behind Tool", [], Some("Y")), uic!("tool.shape", "Shape Tools", [], Some("Q")), uic!("tool.pen", "Pen Tool", [], Some("G")), uic!("tool.type", "Type Tools", [], Some("Cmd+T")), uic!("tool.brush", "Brush Tools", [], Some("Cmd+B")), uic!("tool.rotoBrush", "Roto Brush & Refine Edge Tools", [], Some("Alt+W")), uic!("tool.puppet", "Puppet Tools", [], Some("Cmd+P")), uic!("app.newComp", "New Composition...", [], None), uic!("app.compSettings", "Composition Settings...", [], None), uic!("app.solidSettings", "New Solid...", [], None), uic!("app.home", "Home", [], None), uic!("markers.dialog", "Marker Settings...", [], None), uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")), uic!("window.maximizeApp", "Maximize or Restore Application Window", [], Some("Cmd+\\")), uic!("window.toggleCompTimeline", "Switch Between Composition and Timeline Panel", [], Some("\\")), uic!("window.nextPanel", "Next Panel in Frame", [], Some("Alt+Shift+.")), uic!("window.previousPanel", "Previous Panel in Frame", [], Some("Alt+Shift+,")), uic!("window.closeAll", "Close Panel or All Viewers", [], Some("Cmd+Shift+W")), uic!("window.dockPanel", "Dock Panel", [], None), uic!("window.floatPanel", "Undock Panel", [], None), uic!("window.closePanel", "Close Panel", [], None), uic!("window.panelWindow", "Move Panel to New Window", [], None), ]; pub fn panel_command_id(p: PanelKind) -> String { format!("window.panel.{}", p.id()) } /// The reveal shortcuts (P, S, R, T, A, E, M, F, L…). `add` (Shift+key) adds the property to /// (or removes it from) those already revealed instead of replacing them. pub fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64, add: bool) { // The double-press shortcuts: the same key again within 0.6 s reveals its second set (LL // Waveform, TT Mask Opacity, MM all mask properties, EE Expressions, RR Time Remap, AA // Material Options, PP paint / Roto Brush / Puppet, SS selected properties, FF missing // effects). A single M shows Mask Path. let double = |k: &str| -> Option<&'static str> { Some(match k { "levels" => "waveform", "opacity" => "maskOpacity", "masks" => "masks", "effects" => "expressions", "rotation" => "timeRemap", "anchor" => "material", "position" => "paint", "scale" => "selected", "feather" => "missingEffects", _ => return None, }) }; let pressed = kind; // (With Shift a second press removes the property again instead.) let again = !add && matches!(app.last_reveal.take(), Some((k, t)) if k == pressed && now - t < 0.6); app.last_reveal = Some((if again { String::new() } else { pressed.to_string() }, now)); let kind = match (again, double(pressed)) { (true, Some(d)) => { // The second press replaces what the first one revealed. let first = if pressed == "masks" { "maskPath" } else { pressed }; app.ui.timeline.reveal.retain(|k| k != first); d } _ if pressed == "masks" => "maskPath", _ => pressed, }; let targets = reveal_targets(app); let tl = &mut app.ui.timeline; let present = tl.reveal.iter().any(|k| k == kind); let mut kinds = tl.reveal.clone(); if add { if present { kinds.retain(|k| k != kind); } else { kinds.retain(|k| k != "props" || !tl.reveal_props.is_empty()); kinds.push(kind.to_string()); } } else if present && kinds.len() == 1 { kinds.clear(); } else { kinds = vec![kind.to_string()]; } tl.apply_reveal(&targets, kinds.clone()); crate::panels::timeline::open_revealed(app, &targets, &kinds); } /// Show Time Remap on the layers of a `layer.enableTimeRemap` (`layers` by id, else the /// selected ones) that have it now. fn reveal_time_remap(app: &mut EffectcraftApp, params: &Value) { let asked: Vec = match params.get("layers").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_u64).collect(), None => app.session.state.selected_layers.iter().map(|l| l.0).collect(), }; let Some(comp) = app.session.active_comp() else { return }; let layers: Vec = comp.layers.iter().filter(|l| asked.contains(&l.id.0) && l.props.get("timeRemap").is_some()).map(|l| l.id.0).collect(); if !layers.is_empty() { app.ui.timeline.apply_reveal(&layers, vec!["timeRemap".into()]); } } /// After Alt+Shift+P (A, S, R, T) the Timeline shows the property on each layer it keyed, as in /// After Effects: added to what a reveal shortcut shows, else with the layer's Transform twirled /// open, else alone like P. fn reveal_keyed(app: &mut EffectcraftApp, keyed: &Value, kind: &str) { let layers: std::collections::BTreeSet = keyed.as_array().into_iter().flatten().filter_map(|k| k.get("layer").and_then(Value::as_u64)).collect(); let Some(comp) = app.session.active_comp() else { return }; let tl = &mut app.ui.timeline; for id in layers { let shown = tl.layer_reveal.get(&id).filter(|k| !k.is_empty()).cloned(); match shown { Some(mut kinds) => { if !kinds.iter().any(|k| k == kind) { kinds.push(kind.to_string()); tl.layer_reveal.insert(id, kinds); } } None if tl.open_layers.contains(&id) => { if let Some(tr) = comp.layer(effectcraft_engine::project::LayerId(id)).and_then(|l| l.transform()) { tl.open_groups.insert(tr.uid); } } None => { tl.open_layers.insert(id); tl.layer_reveal.insert(id, vec![kind.to_string()]); } } } } /// The layers a reveal shortcut acts on: the selected ones, else every layer of the active comp. fn reveal_targets(app: &EffectcraftApp) -> Vec { if app.session.state.selected_layers.is_empty() { app.session.active_comp().map(|c| c.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default() } else { app.session.state.selected_layers.iter().map(|l| l.0).collect() } } /// The arrow keys nudge the selected layers 1 pixel at the viewer's magnification (`up`, /// `down`, `left`, `right`), 10 with Shift (`up10`…), as After Effects does, so zoomed in /// they move by fractions of a comp pixel. From any panel except Project (which owns the /// arrow keys for its own keyboard navigation, #961); not while typing, with a dialog or /// menu open. With mask points selected the arrows move the points instead (#1288). fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result { let (side, n) = match dir.strip_suffix("10") { Some(side) => (side, 10.0), None => (dir, 1.0), }; let (x, y) = match side { "up" => (0.0, -n), "down" => (0.0, n), "left" => (-n, 0.0), "right" => (n, 0.0), _ => return Err(format!("unknown nudge `{dir}`")), }; let st = &app.session.state; if app.ui.focused == PanelKind::Project || (st.selected_layers.is_empty() && st.selected_vertices.is_empty()) || st.text_edit.is_some() { return Ok(Value::Null); } // Screen pixels per comp pixel. let zoom = (app.ui.viewer.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx)) * ctx.pixels_per_point()) as f64; let k = if zoom.is_finite() && zoom > 1e-6 { 1.0 / zoom } else { 1.0 }; if let Some(layer) = st.selected_vertices.first().map(|v| v.layer) { let d = vertex_delta(app, layer, [x * k, y * k]); return run_engine(app, ctx, "mask.moveVertices", json!({"delta": d})); } run_engine(app, ctx, "layer.nudge", json!({"x": x * k, "y": y * k})) } /// A comp-space step `d` in the layer space of `layer`, where mask points live. Like a drag of /// the points in the viewer, every selected point takes the first one's layer space. fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerId, d: [f64; 2]) -> [f64; 2] { use effectcraft_engine::geom::vec2; let s = &app.session; let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d }; let Some(l) = comp.layer(layer) else { return d }; let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); l2c.inverse().map_or(d, |inv| { let v = inv.apply_vec(vec2(d[0], d[1])); [v.x, v.y] }) } fn no_params(p: &Value) -> bool { p.as_object().is_none_or(|m| m.is_empty()) } /// `{"comp": id}` and nothing else: Composition Settings opens on that comp instead of /// applying no settings to it. fn only_comp(p: &Value) -> Option { let m = p.as_object().filter(|m| m.len() == 1)?; m.get("comp")?.as_u64().map(effectcraft_engine::project::ItemId) } /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { if matches!(id, "effects.copyPresetsFolderPath" | "mediaBrowser.copySourcePath") { return frontend(app, ctx, id, params); } // Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of // the comp, as After Effects does, so typing goes into Source Text rather than running // shortcuts (#866). `engine.execute` keeps the headless default. if id == "layer.newText" && no_params(¶ms) { return run_engine(app, ctx, id, json!({"text": "", "edit": true})); } // A new folder (File ▸ New, its shortcut, the Project panel's button and context menu) opens // with its name ready to type, as in After Effects (#1658). The command selects it. // `engine.execute` keeps the headless default. if id == "project.newFolder" && no_params(¶ms) { let r = run_engine(app, ctx, id, params); if r.is_ok() { app.show_panel(PanelKind::Project); crate::panels::project::begin_rename(app, ctx); } return r; } if no_params(¶ms) && app.ui.focused == PanelKind::Project { match id { "edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})), "edit.selectAll" => { let items: Vec = crate::panels::project::visible_rows(app) .into_iter() .filter(|(id, _)| app.ui.project_search.is_empty() || app.session.project.item(*id).is_some_and(|it| !it.is_folder())) .map(|(id, _)| id.0) .collect(); return run_engine(app, ctx, "project.select", json!({"items":items})); } "edit.deselectAll" => return run_engine(app, ctx, "project.select", json!({"items":[]})), "edit.cut" | "edit.copy" | "edit.paste" | "edit.copyWithPropertyLinks" | "edit.copyWithRelativePropertyLinks" | "edit.copyExpressionOnly" => { return Err("Project clipboard actions are not supported yet".into()); } _ => {} } } // Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After // Effects 26.3). Copying in the app replaces the system clipboard with a note naming what // was copied, so SVG there was copied after anything copied in the app. if id == "edit.paste" && no_params(¶ms) && app.session.state.text_edit.is_none() { let text = ctx.data_mut(|d| d.remove_temp::(system_paste_id())).or_else(|| app.hooks.clipboard_text.as_ref().and_then(|f| f())); if let Some(svg) = text.filter(|t| effectcraft_engine::vector::is_svg_text(t)) { return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg})); } } // Clear follows panel focus, including the Edit menu and keyboard shortcut. if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project { // Through `invoke`, so deleting items that compositions use still asks first (M3.16). return invoke(app, ctx, "project.delete", json!({})); } if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::RenderQueue { let selected = ctx.data(|d| d.get_temp::<(Vec, Option)>(egui::Id::new("rq-ui"))).and_then(|(_, id)| id); return match selected { Some(item) => run_engine(app, ctx, "renderQueue.remove", json!({"item": item})), None => Err("select a Render Queue item first".into()), }; } // Shift+Delete deletes the Project panel's selection without asking; it does nothing in the // other panels. if id == "project.deleteWithoutConfirmation" { if app.ui.focused != PanelKind::Project { return Ok(Value::Null); } return crate::panels::delete_items::delete_confirmed(app, ctx, json!({})); } let now = ctx.input(|i| i.time); // Closing a modified project asks to save it first; the command runs once answered. if crate::panels::unsaved::guard(app, id, ¶ms) { return Ok(json!({"dialog": "unsavedChanges"})); } // Deleting Project items that compositions use asks first, as in After Effects. if crate::panels::delete_items::guard(app, id, ¶ms) { return Ok(json!({"dialog": "deleteItems"})); } // J / K and Select All Keyframes act on what the Timeline shows (its revealed properties). if matches!(id, "time.nextKey" | "time.previousKey" | "keys.selectAll") && ["visible", "layers", "layer", "prop"].iter().all(|k| params.get(*k).is_none()) && let Some(c) = app.session.active_comp_arc() { let mut p = if params.is_object() { params.clone() } else { json!({}) }; p["visible"] = json!(crate::panels::timeline::visible_props(app, &c)); return run_engine(app, ctx, id, p); } // New Camera/Light and Camera/Light Settings without parameters open their dialogs. if crate::panels::dialogs_3d::route(app, id, ¶ms)? { return Ok(Value::Null); } // Layer Settings on a solid, adjustment layer or null opens its settings dialog. if crate::panels::dialogs::route_layer_settings(app, id, ¶ms)? { return Ok(Value::Null); } // Layer ▸ Layer Styles ▸