mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 11:37:02 -06:00
refactor(ui-egui): move menu commands, dispatch, entries, shortcuts and the menu bar to menus/*.rs
This commit is contained in:
@@ -5433,7 +5433,12 @@ mod tests {
|
||||
("dock.rs", include_str!("../dock.rs")),
|
||||
("dock_ui.rs", include_str!("../dock_ui.rs")),
|
||||
("header.rs", include_str!("../header.rs")),
|
||||
("menus.rs", include_str!("../menus.rs")),
|
||||
("menus/bar.rs", include_str!("../menus/bar.rs")),
|
||||
("menus/commands.rs", include_str!("../menus/commands.rs")),
|
||||
("menus/entries.rs", include_str!("../menus/entries.rs")),
|
||||
("menus/frontend.rs", include_str!("../menus/frontend.rs")),
|
||||
("menus/invoke.rs", include_str!("../menus/invoke.rs")),
|
||||
("menus/shortcuts.rs", include_str!("../menus/shortcuts.rs")),
|
||||
("prefs_live.rs", include_str!("../prefs_live.rs")),
|
||||
("widgets.rs", include_str!("../widgets.rs")),
|
||||
("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")),
|
||||
|
||||
+21
-2318
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
use super::entries::{entry_checked, entry_enabled, entry_label, entry_shortcut, menu_choice};
|
||||
use super::invoke::invoke;
|
||||
use super::shortcuts::shortcut_text;
|
||||
use super::*;
|
||||
|
||||
/// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last
|
||||
/// frame (its title's open look, and the hover that switches between the titles).
|
||||
fn open_menu_id() -> egui::Id {
|
||||
egui::Id::unique("menu-bar-open")
|
||||
}
|
||||
|
||||
/// The id under which [`menu_bar`] remembers a title press still in progress (press-and-browse).
|
||||
fn browse_menu_id() -> egui::Id {
|
||||
egui::Id::unique("menu-bar-browse")
|
||||
}
|
||||
|
||||
/// Draw the in-window menu bar (the engine's AE menu tree).
|
||||
///
|
||||
/// The titles open on the press, not the release (native menu bars): while one is held the menus
|
||||
/// can be browsed by moving onto them, and releasing over an entry chooses it. Hovering another
|
||||
/// title switches the open menu to it, and the open one closes on a press outside it or when the
|
||||
/// window loses focus. The pointer leaving the window — onto the OS title bar, directly above the
|
||||
/// bar — closes nothing, however long: the platform forwards none of the title bar's clicks to
|
||||
/// egui (only the cursor leaving), so there is no click to close on and a graze must not drop the
|
||||
/// menu. The closing press itself still reaches the widget under it, as a click does in After
|
||||
/// Effects.
|
||||
pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut clicked: Option<(String, Value)> = None;
|
||||
let mut nav = crate::menu_keys::Nav::default();
|
||||
// The open menu as of the last frame: the titles are drawn before this frame can open or
|
||||
// close anything, and what happened last frame is what they must react to.
|
||||
let open_prev: Option<usize> = ctx.data(|d| d.get_temp(open_menu_id())).flatten();
|
||||
if ctx.data(|d| d.get_temp(browse_menu_id())).unwrap_or(false) {
|
||||
nav.set_browsing();
|
||||
}
|
||||
let mut drawn = 0;
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.add_space(6.0);
|
||||
egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::widgets::menu_style)).ui(ui, |ui| {
|
||||
for node in effectcraft_engine::menus::menu_bar() {
|
||||
let MenuNode::Submenu { label, children } = node else { continue };
|
||||
// The open title takes the open button's look while its menu is up (as in AE).
|
||||
let inactive = ui.style().visuals.widgets.inactive;
|
||||
if open_prev == Some(drawn) {
|
||||
ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open;
|
||||
}
|
||||
let mut response = ui.add(egui::Button::new(crate::i18n::label(app, "", label)));
|
||||
ui.style_mut().visuals.widgets.inactive = inactive;
|
||||
// A press on a title opens its menu at once (a second press on the open one closes
|
||||
// it), and while it is held the menus below are browsable. The keyboard and the
|
||||
// accessibility activation report `clicked` without the primary button (the press
|
||||
// then happened somewhere else), and toggle the menu. Hovering another title
|
||||
// switches to it once the pointer has moved: merely resting there must not take a
|
||||
// keyboard-driven menu over.
|
||||
let moved = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO);
|
||||
// With the button down elsewhere nothing is hovered, so the title under the
|
||||
// pointer takes the highlight by hand: the hover-switch to it shows, one frame
|
||||
// later like egui's own hover (the open title keeps its open look).
|
||||
if moved && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) {
|
||||
response = response.highlight();
|
||||
}
|
||||
let pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) && response.hovered();
|
||||
let cmd = if pressed {
|
||||
nav.set_browsing();
|
||||
Some(egui::SetOpenCommand::Bool(open_prev != Some(drawn)))
|
||||
} else if response.clicked() && !response.clicked_by(egui::PointerButton::Primary) {
|
||||
Some(egui::SetOpenCommand::Toggle)
|
||||
} else if moved && response.enabled() && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) {
|
||||
Some(egui::SetOpenCommand::Bool(true))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let inner = egui::Popup::menu(&response)
|
||||
.close_behavior(egui::PopupCloseBehavior::IgnoreClicks)
|
||||
.style(crate::widgets::menu_style)
|
||||
.open_memory(cmd)
|
||||
.show(|ui| {
|
||||
ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 });
|
||||
let browsing = nav.browsing();
|
||||
crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0));
|
||||
});
|
||||
nav.top(&response);
|
||||
// The titles and the open menus: a press inside them is not an outside press.
|
||||
nav.cover(response.rect);
|
||||
if let Some(inner) = &inner {
|
||||
nav.cover(inner.response.rect);
|
||||
}
|
||||
app.auto.add(&format!("menu.{label}"), response.rect, label);
|
||||
drawn += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
crate::menu_keys::handle(&ctx, &nav);
|
||||
// egui leaves the closing to us (`IgnoreClicks`), so the menus close here: at once on a press
|
||||
// outside them (the press itself still reaches the widget below) and when the window loses
|
||||
// focus. The pointer leaving the window closes nothing: moving onto the OS title bar fires
|
||||
// the cursor-left event and the platform forwards none of its clicks to egui, so there is no
|
||||
// click to close on — and a graze or a stay must not drop the menu, as in native apps.
|
||||
if let Some(top) = nav.open_popup(&ctx) {
|
||||
let focus_lost = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))));
|
||||
let outside_press = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !nav.covers(p)));
|
||||
if focus_lost || outside_press {
|
||||
egui::Popup::close_id(&ctx, top);
|
||||
}
|
||||
}
|
||||
if let Some((id, params)) = clicked.map(|(id, params)| menu_choice(app, id, params))
|
||||
&& let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params })
|
||||
{
|
||||
app.ui.status = e;
|
||||
}
|
||||
// What the next frame starts from: the open title, and a title press still in progress.
|
||||
let open = nav.open_index(&ctx);
|
||||
let browsing = open.is_some() && nav.browsing() && ctx.input(|i| i.pointer.primary_down());
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(open_menu_id(), open);
|
||||
d.insert_temp(browse_menu_id(), browsing);
|
||||
});
|
||||
}
|
||||
|
||||
/// The entries of top-level menu `name` (the Effect menu is the Effect Controls panel's context
|
||||
/// menu); returns the chosen command and its params.
|
||||
pub(crate) fn menu_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, name: &str) -> Option<(String, Value)> {
|
||||
let mut clicked = None;
|
||||
if let Some(MenuNode::Submenu { children, .. }) =
|
||||
effectcraft_engine::menus::menu_bar().iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == name))
|
||||
{
|
||||
menu_nodes(app, ui, children, &mut clicked, &mut crate::menu_keys::Nav::default(), 0);
|
||||
}
|
||||
clicked.map(|(id, params)| (id, if params.is_null() { json!({}) } else { params }))
|
||||
}
|
||||
|
||||
/// The entries `nodes` of a menu `depth` submenus deep, registered with `nav` for the keyboard.
|
||||
fn menu_nodes(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
nodes: &[MenuNode],
|
||||
clicked: &mut Option<(String, Value)>,
|
||||
nav: &mut crate::menu_keys::Nav,
|
||||
depth: usize,
|
||||
) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
MenuNode::Separator => {
|
||||
ui.separator();
|
||||
}
|
||||
MenuNode::Dynamic { name } => {
|
||||
// Recent projects / footage / presets, undo history, shortcut slots, viewers,
|
||||
// saved workspaces.
|
||||
let ws = app.ui.workspace.clone();
|
||||
let saved = app.saved_workspace_names();
|
||||
let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws, &saved));
|
||||
if entries.is_empty()
|
||||
&& let Some(e) = empty
|
||||
{
|
||||
ui.add_enabled(false, egui::Button::new((gutter(false), e)));
|
||||
}
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let tip = match name.as_str() {
|
||||
"recentProjects" => app.session.prefs.recent_projects.get(i).cloned(),
|
||||
"recentFootage" => app.session.prefs.recent_footage.get(i).cloned(),
|
||||
"recentPresets" => app.session.prefs.recent_presets.get(i).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())));
|
||||
let r = match &tip {
|
||||
Some(t) => r.on_hover_text(t),
|
||||
None => r,
|
||||
};
|
||||
let r = browse_hover(nav, r);
|
||||
app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label);
|
||||
nav.entry(ui, depth, &r, false);
|
||||
if r.clicked() || browse_pick(nav, ui, &r) {
|
||||
*clicked = Some((e.command.clone(), e.params.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
let ws = app.ui.workspace.clone();
|
||||
// The generated Effect menu's category submenus draw the catalog's name (#821);
|
||||
// every other submenu goes through the menu catalog.
|
||||
let shown = crate::i18n::effect_category(children, label)
|
||||
.unwrap_or_else(|| crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[]))));
|
||||
let browsing = nav.browsing();
|
||||
let egui::InnerResponse { inner, response } = ui.menu_button((gutter(false), shown.as_str()), |ui| {
|
||||
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
|
||||
crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1));
|
||||
});
|
||||
let response = browse_hover(nav, response);
|
||||
nav.entry(ui, depth, &response, true);
|
||||
if inner.is_some() {
|
||||
// `ui.menu_button` drops the popup's own response on the floor: read it back,
|
||||
// so that a press inside this submenu doesn't count as an outside press.
|
||||
let sub = egui::containers::menu::SubMenu::id_from_widget_id(response.id);
|
||||
if let Some(resp) = ui.ctx().read_response(sub) {
|
||||
nav.cover(resp.rect);
|
||||
}
|
||||
nav.opened(depth, &response);
|
||||
}
|
||||
}
|
||||
MenuNode::Item(e) => {
|
||||
let r = browse_hover(nav, menu_entry(app, ui, e));
|
||||
nav.entry(ui, depth, &r, false);
|
||||
if r.clicked() || browse_pick(nav, ui, &r) {
|
||||
*clicked = Some((e.command.clone(), e.params.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The row `r` highlighted while the bar is mid press-and-browse: with a button down egui reports
|
||||
/// nothing as hovered — which would leave browsing with no hover feedback at all — so the row
|
||||
/// under the pointer takes the highlight by hand. It reads as the hover look one frame later,
|
||||
/// exactly egui's own hover latency; disabled rows ignore it (`Noninteractive`), as in native
|
||||
/// menus. Context menus use their own [`crate::menu_keys::Nav`], where the bar never browses.
|
||||
fn browse_hover(nav: &crate::menu_keys::Nav, r: egui::Response) -> egui::Response {
|
||||
if nav.browsing() && r.contains_pointer() { r.highlight() } else { r }
|
||||
}
|
||||
|
||||
/// Whether a press-and-browse (a menu-bar title still held down) releases over entry `r`, which
|
||||
/// then chooses it like a click does. egui reports neither for it: the release is far from the
|
||||
/// press, so there is no click, and nothing but the pressed widget is hovered while a button is
|
||||
/// down, so the row has to be tested by the pointer being over it.
|
||||
fn browse_pick(nav: &crate::menu_keys::Nav, ui: &egui::Ui, r: &egui::Response) -> bool {
|
||||
nav.browsing() && r.enabled() && r.contains_pointer() && ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary))
|
||||
}
|
||||
|
||||
/// The menu bar's submenu at `path` (`["File", "Interpret Footage"]`) in a panel's context menu,
|
||||
/// with the menu bar's labels, shortcuts and enabled state; a chosen entry goes to `clicked`.
|
||||
pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&str], clicked: &mut Option<(String, Value)>) {
|
||||
let mut nodes = effectcraft_engine::menus::menu_bar();
|
||||
let mut found = None;
|
||||
for label in path {
|
||||
let Some(n) = nodes.iter().find(|n| matches!(n, MenuNode::Submenu { label: l, .. } if l == label)) else { return };
|
||||
if let MenuNode::Submenu { children, .. } = n {
|
||||
nodes = children;
|
||||
}
|
||||
found = Some(n);
|
||||
}
|
||||
if let Some(n) = found {
|
||||
menu_nodes(app, ui, std::slice::from_ref(n), clicked, &mut crate::menu_keys::Nav::default(), 0);
|
||||
}
|
||||
if let Some((_, p)) = clicked.as_mut()
|
||||
&& p.is_null()
|
||||
{
|
||||
*p = json!({});
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu bar's entry for `command` in a panel's context menu (see [`submenu_at`]).
|
||||
pub(crate) fn entry_for(app: &EffectcraftApp, ui: &mut egui::Ui, command: &str, clicked: &mut Option<(String, Value)>) {
|
||||
if let Some((_, e)) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command)
|
||||
&& menu_entry(app, ui, e).clicked()
|
||||
{
|
||||
*clicked = Some((e.command.clone(), e.params_or_empty()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend state for dynamic menus (the current workspace and the saved ones).
|
||||
pub(crate) fn dyn_ctx<'a>(workspace: &'a str, saved_workspaces: &'a [String]) -> effectcraft_engine::menus::DynCtx<'a> {
|
||||
effectcraft_engine::menus::DynCtx { workspace: Some(workspace), saved_workspaces }
|
||||
}
|
||||
|
||||
/// The check-mark column every menu row reserves (like macOS / After Effects menus).
|
||||
fn gutter(checked: bool) -> egui::Atom<'static> {
|
||||
use egui::AtomExt;
|
||||
(if checked { "✔" } else { "" }).atom_size(egui::vec2(14.0, 14.0))
|
||||
}
|
||||
|
||||
fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> egui::Response {
|
||||
let (label, check, enabled) = (entry_label(app, e), gutter(entry_checked(app, e) == Some(true)), entry_enabled(app, e));
|
||||
// Edit ▸ Label's entries show the label's colour before its name.
|
||||
if e.command == "edit.label"
|
||||
&& let Some(l) = e.params.get("label").and_then(Value::as_str).and_then(effectcraft_engine::color::Label::from_name)
|
||||
{
|
||||
let out = ui.add_enabled_ui(enabled, |ui| egui::Button::new((check, crate::widgets::label_swatch(), label)).atom_ui(ui)).inner;
|
||||
crate::widgets::paint_label_swatch(ui, &out, l, &app.tokens);
|
||||
return out.response;
|
||||
}
|
||||
let mut b = egui::Button::new((check, label));
|
||||
if let Some(s) = entry_shortcut(app, e) {
|
||||
b = b.shortcut_text(shortcut_text(&s));
|
||||
}
|
||||
ui.add_enabled(enabled, b)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use super::*;
|
||||
|
||||
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),
|
||||
];
|
||||
@@ -0,0 +1,147 @@
|
||||
use super::*;
|
||||
|
||||
/// A menu entry for display / `ui.menu.list`.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct MenuItem {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub path: Vec<String>,
|
||||
pub shortcut: Option<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checked: Option<bool>,
|
||||
/// Parameters bound to the entry (e.g. the effect for Effect menu items).
|
||||
#[serde(skip_serializing_if = "Value::is_null")]
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
/// Top-level menus in order.
|
||||
pub fn menus() -> Vec<&'static str> {
|
||||
effectcraft_engine::menus::top_level()
|
||||
}
|
||||
|
||||
pub(crate) fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
|
||||
let shown = match e.command.as_str() {
|
||||
"edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()),
|
||||
"edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()),
|
||||
// Window ▸ Layer: the layer open in the Layer panel.
|
||||
"window.panel" if e.params.get("panel").and_then(Value::as_str) == Some("layer") => {
|
||||
let name = app
|
||||
.ui
|
||||
.layer_panel
|
||||
.and_then(|id| app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(id))))
|
||||
.map(|l| l.name.clone());
|
||||
format!("Layer: {}", name.unwrap_or_else(|| "(none)".into()))
|
||||
}
|
||||
_ => effectcraft_engine::menus::entry_label(&app.session, e),
|
||||
};
|
||||
crate::i18n::entry(app, e, shown)
|
||||
}
|
||||
|
||||
/// The entry's shortcut in the active keyboard shortcut preset.
|
||||
pub(crate) fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option<String> {
|
||||
app.session.shortcuts().shortcut_of(&e.command, &e.params).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Whether panel `p` is on screen: the front tab of a floating window, or of a docked group
|
||||
/// (an open frame in a stack) that a maximized panel does not cover.
|
||||
pub(crate) fn panel_shown(app: &EffectcraftApp, p: PanelKind) -> bool {
|
||||
if app.ui.dock.is_floating(p) {
|
||||
return app.ui.dock.is_visible(p);
|
||||
}
|
||||
match app.ui.maximized.filter(|m| app.ui.dock.is_docked(*m)) {
|
||||
Some(m) => m == p,
|
||||
None => app.ui.dock.is_visible(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// The command a menu entry runs when chosen from a menu: Window ▸ `<panel>` closes a panel whose
|
||||
/// entry is checked, as in After Effects (#1630). Shortcuts and agents calling `window.panel`
|
||||
/// still only show the panel.
|
||||
pub(crate) fn menu_choice(app: &EffectcraftApp, id: String, params: Value) -> (String, Value) {
|
||||
if id == "window.panel"
|
||||
&& let Some(p) = params.get("panel").and_then(Value::as_str).and_then(PanelKind::from_name)
|
||||
&& panel_shown(app, p)
|
||||
{
|
||||
return ("window.closePanel".into(), params);
|
||||
}
|
||||
(id, params)
|
||||
}
|
||||
|
||||
/// Check-mark state of frontend toggles (engine state is answered by the engine).
|
||||
pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool> {
|
||||
let v = &app.ui.viewer;
|
||||
let pstr = |k: &str| e.params.get(k).and_then(Value::as_str);
|
||||
match e.command.as_str() {
|
||||
"view.rulers" => Some(v.rulers),
|
||||
"view.guides" => Some(v.guides),
|
||||
"view.snapToGuides" => Some(v.snap_guides),
|
||||
"view.lockGuides" => Some(v.lock_guides),
|
||||
"view.grid" => Some(v.grid),
|
||||
"view.snapToGrid" => Some(v.snap_grid),
|
||||
"view.layerControls" => Some(v.show_layer_controls),
|
||||
"playback.cacheWhenIdle" => Some(app.ui.cache_when_idle),
|
||||
"playback.audio" => Some(app.session.prefs.preview.active().include_audio),
|
||||
"window.workspace" => Some(pstr("name").is_some_and(|n| n == app.ui.workspace)),
|
||||
// The Tools panel is the header toolbar: always on screen.
|
||||
"window.panel" => Some(pstr("panel").is_some_and(|n| n.eq_ignore_ascii_case("tools") || PanelKind::from_name(n).is_some_and(|p| panel_shown(app, p)))),
|
||||
"view.panelBackground" if e.params.get("pick").is_none() => {
|
||||
let cur = match v.pasteboard {
|
||||
None => "mediumGray",
|
||||
Some([0, 0, 0]) => "black",
|
||||
Some([0x2a, 0x2a, 0x2a]) => "darkGray",
|
||||
Some([0xb4, 0xb4, 0xb4]) => "lightGray",
|
||||
Some([0xff, 0xff, 0xff]) => "white",
|
||||
Some(_) => "custom",
|
||||
};
|
||||
Some(pstr("color") == Some(cur))
|
||||
}
|
||||
_ => effectcraft_engine::menus::checked(&app.session, &e.command, &e.params),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
|
||||
// The Project-panel enabled rules mirror the invoke redirects, so they stand down for the
|
||||
// same reason (see the redirect block): while a text edit is open the menu items must be
|
||||
// enabled by what their routed command can do, or the menu grays out commands that work.
|
||||
if app.ui.focused == PanelKind::Project && app.session.state.text_edit.is_none() {
|
||||
match e.command.as_str() {
|
||||
"edit.duplicate" => {
|
||||
return !app.session.state.project_selection.is_empty()
|
||||
&& app.session.state.project_selection.iter().all(|id| app.session.project.item(*id).is_some_and(|i| !i.is_folder()));
|
||||
}
|
||||
"edit.selectAll" => return !app.session.project.items.is_empty(),
|
||||
"edit.deselectAll" => return !app.session.state.project_selection.is_empty(),
|
||||
"edit.cut" | "edit.copy" | "edit.paste" | "edit.copyWithPropertyLinks" | "edit.copyWithRelativePropertyLinks" | "edit.copyExpressionOnly" => {
|
||||
return false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if e.command == "edit.clear" && app.ui.focused == PanelKind::Project {
|
||||
return app.session.is_enabled("project.delete");
|
||||
}
|
||||
if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue {
|
||||
return !app.session.is_rendering() && !app.session.project.render_queue.is_empty();
|
||||
}
|
||||
if e.command == "window.panel" {
|
||||
return true;
|
||||
}
|
||||
app.session.is_enabled(&e.command)
|
||||
}
|
||||
|
||||
/// Every menu entry, flattened with its submenu path.
|
||||
pub fn menu_items(app: &EffectcraftApp) -> Vec<MenuItem> {
|
||||
effectcraft_engine::menus::entries()
|
||||
.into_iter()
|
||||
.map(|(path, e)| MenuItem {
|
||||
id: e.command.clone(),
|
||||
label: entry_label(app, e),
|
||||
path,
|
||||
shortcut: entry_shortcut(app, e),
|
||||
enabled: entry_enabled(app, e),
|
||||
checked: entry_checked(app, e),
|
||||
params: e.params.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
use super::*;
|
||||
|
||||
/// Run an engine command and perform the frontend events it emitted right away.
|
||||
/// Commands that fill the internal clipboard.
|
||||
const COPY_COMMANDS: &[&str] =
|
||||
&["edit.copy", "edit.cut", "edit.copyWithPropertyLinks", "edit.copyWithRelativePropertyLinks", "edit.copyExpressionOnly", "keys.copy", "effect.copy"];
|
||||
|
||||
/// A line for the system clipboard naming what EffectCraft copied. The system clipboard must
|
||||
/// hold something: with nothing on it, the windowing layer sends no paste event for Ctrl+V.
|
||||
fn clipboard_note(s: &effectcraft_engine::Session) -> String {
|
||||
let st = &s.state;
|
||||
let n = |n: usize, one: &str, many: &str| format!("EffectCraft: {n} {}", if n == 1 { one } else { many });
|
||||
if st.clip_is_keys && !st.key_clipboard.is_empty() {
|
||||
n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes")
|
||||
} else if !st.effect_clipboard.is_empty() {
|
||||
n(st.effect_clipboard.len(), "effect", "effects")
|
||||
} else if !st.contents_clipboard.is_empty() {
|
||||
n(st.contents_clipboard.len(), "shape item", "shape items")
|
||||
} else if st.link_clipboard.is_some() {
|
||||
"EffectCraft: property links".into()
|
||||
} else {
|
||||
n(st.clipboard.len(), "layer", "layers")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
// Events already queued belong to earlier invocations (`engine.execute` defers them).
|
||||
let queued = app.session.events.len();
|
||||
let mut r = app.session.execute(id, params).map_err(|e| e.to_string());
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
} else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() {
|
||||
ctx.copy_text(clipboard_note(&app.session));
|
||||
}
|
||||
let events = app.session.drain_events();
|
||||
for (index, ev) in events.into_iter().enumerate() {
|
||||
match ev {
|
||||
effectcraft_engine::Event::Frontend { command, params } => {
|
||||
if let Err(e) = frontend(app, ctx, &command, params) {
|
||||
app.ui.status = e.clone();
|
||||
// A frontend-only command's own failure is the command's result (#696: a
|
||||
// workspace save that didn't reach disk reported success). An earlier
|
||||
// invocation's failure only sets the status.
|
||||
if index >= queued && command == id && r.is_ok() {
|
||||
r = Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
other => app.session.events.push(other),
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn toggle(slot: &mut bool, p: &Value) -> Value {
|
||||
*slot = p.get("value").and_then(Value::as_bool).unwrap_or(!*slot);
|
||||
json!(*slot)
|
||||
}
|
||||
|
||||
/// Perform a frontend command (from `Event::Frontend`).
|
||||
pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Value) -> Result<Value, String> {
|
||||
let now = app.now();
|
||||
let v = &mut app.ui.viewer;
|
||||
Ok(match id {
|
||||
"mediaBrowser.copySourcePath" => {
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|path| !path.is_empty()).ok_or("A source path is required")?;
|
||||
ctx.copy_text(path.to_string());
|
||||
json!({"path": path})
|
||||
}
|
||||
"effects.copyPresetsFolderPath" => {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err("The web app has no local presets folder".into());
|
||||
}
|
||||
let path = crate::panels::effects_presets::presets_dir().filter(|path| !path.as_os_str().is_empty()).ok_or("No presets folder is configured")?;
|
||||
let path = std::path::absolute(path).map_err(|e| format!("Cannot resolve the presets folder path: {e}"))?;
|
||||
let path = path.to_str().ok_or("The presets folder path is not valid UTF-8")?;
|
||||
ctx.copy_text(path.to_string());
|
||||
json!({"path": path})
|
||||
}
|
||||
"app.checkForUpdates" | "help.checkForUpdates" => {
|
||||
crate::updates_ui::check(app);
|
||||
Value::Null
|
||||
}
|
||||
"app.about" => {
|
||||
app.dialog = Some(crate::Dialog::About);
|
||||
Value::Null
|
||||
}
|
||||
// Window ▸ <ScriptUI panel>: dock (or bring forward) the panel the script built.
|
||||
// Audio scrubbing: one frame of audio at `time` (default the current time).
|
||||
"playback.scrubAudio" => {
|
||||
let cid = app.session.active_comp_id().ok_or("no composition")?;
|
||||
let t = p.get("time").and_then(Value::as_f64).map_or(app.session.time(), effectcraft_engine::time::Tick::from_seconds_f64);
|
||||
app.scrub_audio(cid, t, now);
|
||||
json!({"playing": app.scrub.is_some()})
|
||||
}
|
||||
// View ▸ New Viewer.
|
||||
"view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}),
|
||||
// Reveal Layer Source / Reveal Composition: show the Project panel on the item, opening
|
||||
// its folders and clearing the search so the row is visible.
|
||||
"project.reveal" => {
|
||||
let ids: Vec<effectcraft_engine::project::ItemId> = p
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().filter_map(Value::as_u64).map(effectcraft_engine::project::ItemId).collect())
|
||||
.or_else(|| p.get("item").and_then(Value::as_u64).map(|item| vec![effectcraft_engine::project::ItemId(item)]))
|
||||
.unwrap_or_default();
|
||||
crate::panels::project::reveal(app, ctx, &ids);
|
||||
json!({"items": ids.iter().map(|item| item.0).collect::<Vec<_>>()})
|
||||
}
|
||||
// Run by id (control channel, MCP): the Project panel's selection, whatever has focus.
|
||||
"project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?,
|
||||
// A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor.
|
||||
"prop.editGradient" => crate::panels::gradient_editor::open(app, &p)?,
|
||||
"window.scriptPanel" => {
|
||||
let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32;
|
||||
app.show_panel(PanelKind::ScriptPanel(id));
|
||||
json!({"window": id})
|
||||
}
|
||||
"layer.style.options" => {
|
||||
crate::panels::layer_styles_dialog::open(app, &p)?;
|
||||
Value::Null
|
||||
}
|
||||
"app.settings" => {
|
||||
let page = p.get("page").and_then(Value::as_str).unwrap_or("general");
|
||||
crate::panels::settings::open(app, effectcraft_engine::prefs::page_id(page).unwrap_or("general"));
|
||||
Value::Null
|
||||
}
|
||||
"app.gpuInfo" => {
|
||||
crate::panels::dialogs::info(
|
||||
app,
|
||||
"GPU Information",
|
||||
&format!(
|
||||
"{}Layer cache: {} MB • Preview cache: {} MB.",
|
||||
effectcraft_engine::sysinfo::gpu_summary(&app.session),
|
||||
app.session.layer_cache.budget() >> 20,
|
||||
app.frames.budget() >> 20
|
||||
),
|
||||
);
|
||||
Value::Null
|
||||
}
|
||||
"app.hide" | "app.hideOthers" | "app.showAll" => {
|
||||
// The desktop app asks macOS (NSApplication hide / hideOtherApplications /
|
||||
// unhideAllApplications); elsewhere Hide minimizes and the others don't apply.
|
||||
if app.hooks.app_action.as_ref().is_some_and(|f| f(id)) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
match id {
|
||||
"app.hide" => ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)),
|
||||
_ => return Err(format!("`{id}` is only available on macOS")),
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
"app.quit" => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
Value::Null
|
||||
}
|
||||
"app.commandPalette" => {
|
||||
app.dialog_state.palette_query = p.get("query").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
app.dialog_state.palette_sel = 0;
|
||||
app.dialog = Some(crate::Dialog::CommandPalette);
|
||||
Value::Null
|
||||
}
|
||||
"app.keyboardShortcuts" => {
|
||||
crate::panels::shortcut_editor::open(app);
|
||||
Value::Null
|
||||
}
|
||||
"app.templates" => {
|
||||
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("renderSettings");
|
||||
crate::panels::rq_templates::open(app, ctx, kind);
|
||||
Value::Null
|
||||
}
|
||||
"renderQueue.notify" => {
|
||||
// Render finished with Notify on: a toast, the dock/taskbar attention request, and the
|
||||
// host's system sound (the desktop app handles `renderQueue.notify`).
|
||||
let msg = p.get("message").and_then(Value::as_str).unwrap_or("Render finished").to_string();
|
||||
app.toast = Some((msg, now));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
|
||||
if let Some(f) = app.hooks.app_action.as_ref() {
|
||||
f("renderQueue.notify");
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
"app.find" => {
|
||||
if let Some(q) = p.get("query").and_then(Value::as_str) {
|
||||
app.ui.project_search = q.to_string();
|
||||
}
|
||||
app.show_panel(PanelKind::Project);
|
||||
Value::Null
|
||||
}
|
||||
"playback.toggle" => {
|
||||
// The play button and Spacebar: `{shortcut?}` picks which Preview panel shortcut's
|
||||
// options to play with (default Spacebar).
|
||||
let sc = match p.get("shortcut").and_then(Value::as_str) {
|
||||
Some(k) => effectcraft_engine::preview::PreviewShortcut::parse(k).ok_or_else(|| format!("unknown preview shortcut `{k}`"))?,
|
||||
None => effectcraft_engine::preview::PreviewShortcut::Spacebar,
|
||||
};
|
||||
app.toggle_play_with(now, sc);
|
||||
json!({"playing": app.playback.playing, "shortcut": sc.id()})
|
||||
}
|
||||
"playback.cacheWhenIdle" => {
|
||||
let r = toggle(&mut app.ui.cache_when_idle, &p);
|
||||
app.set_pref("previews.cacheFramesWhenIdle", r.clone())?;
|
||||
r
|
||||
}
|
||||
"playback.audio" => {
|
||||
// Include Audio of the shortcut shown in the Preview panel.
|
||||
let mut on = app.session.prefs.preview.active().include_audio;
|
||||
let r = toggle(&mut on, &p);
|
||||
app.session.execute("playback.settings.set", json!({"includeAudio": on})).map_err(|e| e.to_string())?;
|
||||
if !on {
|
||||
app.audio = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
// Zoom In / Zoom Out act on the focused viewer: the Layer panel when it has focus, else
|
||||
// the Composition viewer.
|
||||
"view.zoomIn" | "view.zoomOut" if app.ui.focused == PanelKind::Layer => {
|
||||
let cur = app.ui.layer_zoom.unwrap_or_else(|| crate::panels::layer_panel::last_fit(ctx));
|
||||
// The Layer panel zooms in points (pixels-per-point 1).
|
||||
let next = zoom_step(cur, 1.0, id == "view.zoomIn");
|
||||
app.ui.layer_zoom = Some(next);
|
||||
json!({"zoom": next, "panel": "layer"})
|
||||
}
|
||||
"view.zoomIn" | "view.zoomOut" => {
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let cur = v.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx));
|
||||
let next = zoom_step(cur, ppp, id == "view.zoomIn");
|
||||
v.zoom = Some(next);
|
||||
json!({"zoom": next})
|
||||
}
|
||||
// View ▸ Resolution ▸ Custom without a factor (the engine sets the comp's resolution).
|
||||
"view.res.custom" => {
|
||||
let cur = match app.viewer_res() {
|
||||
Resolution::Custom(n) => f64::from(n),
|
||||
_ => 2.0,
|
||||
};
|
||||
crate::panels::dialogs::form(
|
||||
app,
|
||||
"Custom Resolution",
|
||||
"view.res.custom",
|
||||
json!({}),
|
||||
vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)],
|
||||
);
|
||||
Value::Null
|
||||
}
|
||||
"view.rulers" => toggle(&mut v.rulers, &p),
|
||||
"view.guides" => toggle(&mut v.guides, &p),
|
||||
"view.snapToGuides" => toggle(&mut v.snap_guides, &p),
|
||||
"view.lockGuides" => toggle(&mut v.lock_guides, &p),
|
||||
"view.grid" => toggle(&mut v.grid, &p),
|
||||
"view.snapToGrid" => toggle(&mut v.snap_grid, &p),
|
||||
"view.layerControls" => toggle(&mut v.show_layer_controls, &p),
|
||||
"view.options" => {
|
||||
app.dialog = Some(crate::Dialog::ViewOptions);
|
||||
Value::Null
|
||||
}
|
||||
"view.panelBackground" => {
|
||||
if p.get("pick").and_then(Value::as_bool) == Some(true) {
|
||||
let [r, g, b] = v.custom_pasteboard;
|
||||
crate::panels::dialogs::form(
|
||||
app,
|
||||
"Panel Background Color",
|
||||
"view.panelBackground",
|
||||
json!({}),
|
||||
vec![crate::panels::dialogs::Field::text("color", "Color (#rrggbb)", &format!("#{r:02x}{g:02x}{b:02x}"))],
|
||||
);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let c = p.get("color").and_then(Value::as_str).unwrap_or("mediumGray");
|
||||
v.pasteboard = match c {
|
||||
"black" => Some([0, 0, 0]),
|
||||
"darkGray" => Some([0x2a, 0x2a, 0x2a]),
|
||||
"mediumGray" => None,
|
||||
"lightGray" => Some([0xb4, 0xb4, 0xb4]),
|
||||
"white" => Some([0xff, 0xff, 0xff]),
|
||||
"custom" => Some(v.custom_pasteboard),
|
||||
hex => {
|
||||
let c = effectcraft_engine::color::Rgba::from_hex(hex).ok_or_else(|| format!("unknown colour `{hex}`"))?;
|
||||
let rgb = [(c.r * 255.0).round() as u8, (c.g * 255.0).round() as u8, (c.b * 255.0).round() as u8];
|
||||
v.custom_pasteboard = rgb;
|
||||
Some(rgb)
|
||||
}
|
||||
};
|
||||
Value::Null
|
||||
}
|
||||
// Ctrl+\ / Cmd+\, as in After Effects (#336): `{maximized}`, the window's new state.
|
||||
"window.maximizeApp" => {
|
||||
let maximized = !ctx.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized));
|
||||
json!({"maximized": maximized})
|
||||
}
|
||||
"view.fullScreen" => {
|
||||
let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full));
|
||||
json!(!full)
|
||||
}
|
||||
"window.panel" => {
|
||||
let name = p.get("panel").and_then(Value::as_str).unwrap_or_default();
|
||||
if name.eq_ignore_ascii_case("tools") {
|
||||
app.ui.status = "The Tools panel is the toolbar in the header".into();
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let panel = PanelKind::from_name(name).ok_or_else(|| format!("unknown panel `{name}`"))?;
|
||||
app.show_panel(panel);
|
||||
Value::Null
|
||||
}
|
||||
"window.workspace" => {
|
||||
let want = p.get("name").and_then(Value::as_str).unwrap_or("Default").to_ascii_lowercase().replace(' ', "");
|
||||
let name = app
|
||||
.workspace_names()
|
||||
.into_iter()
|
||||
.find(|w| w.to_ascii_lowercase().replace(' ', "") == want)
|
||||
.ok_or_else(|| format!("unknown workspace `{want}`"))?;
|
||||
app.set_workspace(&name);
|
||||
json!({"workspace": name})
|
||||
}
|
||||
"window.resetWorkspace" => {
|
||||
let name = app.ui.workspace.clone();
|
||||
app.set_workspace(&name);
|
||||
Value::Null
|
||||
}
|
||||
"window.saveWorkspace" => {
|
||||
let name = app.ui.workspace.clone();
|
||||
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
|
||||
app.store_saved_workspaces()?;
|
||||
json!({"workspace": name})
|
||||
}
|
||||
"window.saveWorkspaceAs" => {
|
||||
let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) else {
|
||||
crate::panels::dialogs::form(
|
||||
app,
|
||||
"New Workspace",
|
||||
"window.saveWorkspaceAs",
|
||||
json!({}),
|
||||
vec![crate::panels::dialogs::Field::text("name", "Name", "Untitled Workspace")],
|
||||
);
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
|
||||
app.ui.workspace = name.clone();
|
||||
app.store_saved_workspaces()?;
|
||||
json!({"workspace": name})
|
||||
}
|
||||
"window.editWorkspaces" => {
|
||||
let Some(name) = p.get("name").and_then(Value::as_str).map(str::to_string) else {
|
||||
let cur = app.ui.workspace.clone();
|
||||
crate::panels::dialogs::form(
|
||||
app,
|
||||
"Edit Workspaces",
|
||||
"window.editWorkspaces",
|
||||
json!({}),
|
||||
vec![
|
||||
crate::panels::dialogs::Field::text("name", "Workspace", &cur),
|
||||
crate::panels::dialogs::Field::text("rename", "Rename to", &cur),
|
||||
crate::panels::dialogs::Field::bool("delete", "Delete", false),
|
||||
],
|
||||
);
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
let builtin = crate::dock::WORKSPACES.contains(&name.as_str());
|
||||
if p.get("delete").and_then(Value::as_bool) == Some(true) {
|
||||
if app.ui.saved_workspaces.remove(&name).is_none() && !builtin {
|
||||
return Err(format!("no workspace `{name}`"));
|
||||
}
|
||||
// Leave the deleted workspace first: a current workspace needs a definition.
|
||||
if app.ui.workspace == name {
|
||||
app.set_workspace("Default");
|
||||
}
|
||||
app.store_saved_workspaces()?;
|
||||
return Ok(json!({"deleted": name}));
|
||||
}
|
||||
if let Some(new) = p.get("rename").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty() && *n != name) {
|
||||
if builtin {
|
||||
return Err(format!("`{name}` is a built-in workspace: save it as a new workspace instead"));
|
||||
}
|
||||
let dock = app.ui.saved_workspaces.remove(&name).ok_or_else(|| format!("no workspace `{name}`"))?;
|
||||
app.ui.saved_workspaces.insert(new.to_string(), dock);
|
||||
if app.ui.workspace == name {
|
||||
app.ui.workspace = new.to_string();
|
||||
}
|
||||
app.store_saved_workspaces()?;
|
||||
return Ok(json!({"renamed": new}));
|
||||
}
|
||||
json!({"workspaces": app.workspace_names()})
|
||||
}
|
||||
"view.lookAt" => {
|
||||
let r: Vec<f64> = p.get("rect").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
if r.len() == 4 {
|
||||
let map: Option<crate::panels::viewer::ViewerMap> = ctx.data(|d| d.get_temp(egui::Id::unique("viewer-map")));
|
||||
if let Some(m) = map {
|
||||
let (rw, rh) = (((r[2] - r[0]) as f32).max(1.0), ((r[3] - r[1]) as f32).max(1.0));
|
||||
let zoom = ((m.area.width() - 40.0) / rw).min((m.area.height() - 40.0) / rh).clamp(0.015, 32.0);
|
||||
let rc = [(r[0] + r[2]) as f32 / 2.0, (r[1] + r[3]) as f32 / 2.0];
|
||||
let v = &mut app.ui.viewer;
|
||||
v.zoom = Some(zoom);
|
||||
v.pan = [m.comp[0] * zoom / 2.0 - rc[0] * zoom, m.comp[1] * zoom / 2.0 - rc[1] * zoom];
|
||||
}
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
"comp.flowchart" => {
|
||||
// Chart the active comp; Layer ▸ Reveal ▸ Reveal Layer in Project Flowchart shows
|
||||
// layers and selects the layer's node.
|
||||
app.ui.flowchart.root = app.session.active_comp_id().map(|c| c.0);
|
||||
if let (Some(c), Some(l)) = (app.session.active_comp_id(), app.session.state.selected_layers.first()) {
|
||||
app.ui.flowchart.layers = true;
|
||||
app.ui.flowchart.selected = Some(format!("layer:{}:{}", c.0, l.0));
|
||||
}
|
||||
app.show_panel(PanelKind::Flowchart);
|
||||
Value::Null
|
||||
}
|
||||
"comp.miniFlowchart" => {
|
||||
let at = ctx.input(|i| i.pointer.hover_pos()).unwrap_or_else(|| ctx.content_rect().center());
|
||||
app.ui.mini_flowchart = Some([at.x, at.y]);
|
||||
Value::Null
|
||||
}
|
||||
"track.editTargetDialog" => {
|
||||
crate::panels::tracker::open_target(app)?;
|
||||
Value::Null
|
||||
}
|
||||
"track.optionsDialog" => {
|
||||
crate::panels::tracker::open_options(app)?;
|
||||
Value::Null
|
||||
}
|
||||
"layer.openLayer" => {
|
||||
let id = p.get("layer").and_then(Value::as_u64).or_else(|| app.session.state.selected_layers.first().map(|l| l.0));
|
||||
// Another layer opens at Fit.
|
||||
if id != app.ui.layer_panel {
|
||||
app.ui.layer_zoom = None;
|
||||
app.ui.layer_pan = [0.0, 0.0];
|
||||
}
|
||||
app.ui.layer_panel = id;
|
||||
app.ui.layer_view = None;
|
||||
app.show_panel(PanelKind::Layer);
|
||||
Value::Null
|
||||
}
|
||||
"app.confirmExecute" => {
|
||||
// Settings ▸ Scripting ▸ Warn User When Executing Files.
|
||||
let path = p.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
crate::panels::forms::form(
|
||||
app,
|
||||
"A script wants to open a file",
|
||||
"file.executeFile",
|
||||
json!({"path": path, "confirmed": true}),
|
||||
vec![crate::panels::forms::Field::text("path", "Open", &path)],
|
||||
);
|
||||
Value::Null
|
||||
}
|
||||
"app.showReport" => {
|
||||
let title = p.get("title").and_then(Value::as_str).unwrap_or("Report").to_string();
|
||||
let text = p.get("text").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
crate::panels::dialogs::info(app, &title, &text);
|
||||
Value::Null
|
||||
}
|
||||
"window.assignWorkspaceShortcut" => {
|
||||
let mut q = p.clone();
|
||||
q["workspace"] = json!(app.ui.workspace);
|
||||
return run_engine(app, ctx, "window.assignWorkspaceShortcut", q);
|
||||
}
|
||||
"file.interpretFootage" => {
|
||||
// Import ▸ Interpret Unlabeled Alpha As: Ask User.
|
||||
if let Some(i) = p.get("item").and_then(Value::as_u64) {
|
||||
app.session.state.project_selection = vec![effectcraft_engine::project::ItemId(i)];
|
||||
}
|
||||
crate::panels::dialogs::open_form(app, "file.interpretFootage", &json!({}));
|
||||
Value::Null
|
||||
}
|
||||
// Help ▸ In-App Tutorials: the Home screen's Learn tab.
|
||||
"help.inAppTutorials" => {
|
||||
app.ui.start_screen = true;
|
||||
app.ui.home_learn = true;
|
||||
app.ui.home_templates = false;
|
||||
Value::Null
|
||||
}
|
||||
// File ▸ New ▸ New Project from Template…: the Home screen's Templates tab.
|
||||
"file.newFromTemplate" => {
|
||||
app.ui.start_screen = true;
|
||||
app.ui.home_learn = false;
|
||||
app.ui.home_templates = true;
|
||||
Value::Null
|
||||
}
|
||||
"effect.manage" | "anim.browsePresets" => {
|
||||
app.show_panel(PanelKind::EffectsPresets);
|
||||
Value::Null
|
||||
}
|
||||
"timeline.revealProps" => {
|
||||
let props = p.get("props").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let uids = |k: &str| -> Vec<u64> { props.iter().filter_map(|x| x.get(k).and_then(Value::as_u64)).collect() };
|
||||
let (found, with_props) = (uids("prop"), uids("layer"));
|
||||
// The layers it looked at (`layers`), else those it found something on. Layers that
|
||||
// have nothing to show close; the others' reveals stay.
|
||||
let targets: Vec<u64> = match p.get("layers").and_then(Value::as_array) {
|
||||
Some(l) => l.iter().filter_map(Value::as_u64).collect(),
|
||||
None => with_props.clone(),
|
||||
};
|
||||
// The targets' earlier picks go (U after UU shows the keyframed ones only).
|
||||
let comp = app.session.active_comp_arc();
|
||||
let theirs = |u: u64| {
|
||||
comp.as_ref().is_some_and(|c| {
|
||||
targets
|
||||
.iter()
|
||||
.filter_map(|l| c.layer(effectcraft_engine::project::LayerId(*l)))
|
||||
.any(|l| l.props.find(u).is_some() || l.props.find_group(u).is_some())
|
||||
})
|
||||
};
|
||||
let tl = &mut app.ui.timeline;
|
||||
tl.reveal_props.retain(|u| !theirs(*u));
|
||||
tl.reveal_props.extend(found.iter().copied());
|
||||
let (shown, hidden): (Vec<u64>, Vec<u64>) = targets.into_iter().partition(|l| with_props.contains(l));
|
||||
tl.apply_reveal(&hidden, vec![]);
|
||||
if !shown.is_empty() {
|
||||
tl.apply_reveal(&shown, vec!["props".into()]);
|
||||
}
|
||||
// Properties inside effects (puppet pins…) show under their effect, twirled open.
|
||||
for (layer, prop) in props.iter().filter_map(|x| Some((x.get("layer")?.as_u64()?, x.get("prop")?.as_u64()?))) {
|
||||
crate::panels::timeline::open_effect_paths(app, layer, &[prop]);
|
||||
}
|
||||
json!({"revealed": found.len()})
|
||||
}
|
||||
_ => return Err(format!("`{id}` is not a frontend command")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Commands that need a file or folder path: ask the host's file dialog when `params` lacks one.
|
||||
pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
enum Ask {
|
||||
Folder,
|
||||
Import,
|
||||
OpenProject,
|
||||
Save(&'static str),
|
||||
Open(&'static [&'static str]),
|
||||
}
|
||||
let (key, ask) = match id {
|
||||
"file.import" | "file.importMultiple" => ("paths", Ask::Import),
|
||||
"file.open" => ("path", Ask::OpenProject),
|
||||
"file.save" if app.session.path.is_none() => ("path", Ask::Save("Untitled Project.ecproj")),
|
||||
"file.saveAs" | "file.saveCopy" => ("path", Ask::Save("Untitled Project.ecproj")),
|
||||
"comp.saveFrameAs" => ("path", Ask::Save("Frame.png")),
|
||||
"comp.saveFrameAsPsd" => ("path", Ask::Save("Frame.psd")),
|
||||
"comp.saveFrameAsExr" => ("path", Ask::Save("Frame.exr")),
|
||||
"file.watchFolder" if params.get("stop").is_none() => ("folder", Ask::Save("Watch Folder")),
|
||||
"anim.savePreset" => ("path", Ask::Save("Preset.ecpreset")),
|
||||
"anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])),
|
||||
"view.exportGuides" => ("path", Ask::Save("Guides.json")),
|
||||
"view.importGuides" => ("path", Ask::Open(&["json"])),
|
||||
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
|
||||
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
|
||||
"file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])),
|
||||
"file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])),
|
||||
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
|
||||
"file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||
"file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])),
|
||||
"effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
"file.relinkFootage" => ("folder", Ask::Folder),
|
||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
||||
"file.saveCopyAsXml" => ("path", Ask::Save("Untitled Project.ecprojx")),
|
||||
"keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])),
|
||||
"essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")),
|
||||
"essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])),
|
||||
"file.setProxy" => {
|
||||
("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"]))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if params.get(key).is_some()
|
||||
|| params.get("path").is_some()
|
||||
|| params.get("paths").is_some()
|
||||
|| params.get("steps").is_some()
|
||||
|| params.get("preset").is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
const MEDIA: &[&str] = &[
|
||||
"mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3",
|
||||
"flac", "ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
|
||||
];
|
||||
// A host whose blocking dialogs are unsafe inside this frame shows the dialog without
|
||||
// blocking — macOS: the nested event loop re-entered winit's handler on a drag over the
|
||||
// window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped,
|
||||
// so the compositor marks the window as not responding (#1275) — and the command continues
|
||||
// when the answer arrives.
|
||||
if let Some(spawn) = app.hooks.file_dialog_async.as_ref() {
|
||||
let request = match ask {
|
||||
Ask::Folder => crate::FileDialog::Folder,
|
||||
Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" },
|
||||
Ask::OpenProject => crate::FileDialog::OpenProject,
|
||||
Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false },
|
||||
Ask::Save(default) => crate::FileDialog::save(default),
|
||||
};
|
||||
let tx = app.dialog_answers.0.clone();
|
||||
let answer = crate::DialogAnswer { command: id.to_string(), key, params: params.clone(), paths: Vec::new() };
|
||||
app.file_dialogs.opened();
|
||||
spawn(
|
||||
request,
|
||||
Box::new(move |paths| {
|
||||
let _ = tx.send(crate::DialogAnswer { paths, ..answer });
|
||||
}),
|
||||
);
|
||||
return Some(Ok(json!({"dialog": id})));
|
||||
}
|
||||
let picked: Option<Value> = match ask {
|
||||
Ask::Folder => {
|
||||
let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) };
|
||||
f().map(Value::from)
|
||||
}
|
||||
Ask::Import => {
|
||||
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
|
||||
picked_value(key, f(MEDIA))
|
||||
}
|
||||
Ask::OpenProject => {
|
||||
let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) };
|
||||
f().map(|s| json!(s))
|
||||
}
|
||||
Ask::Open(exts) => {
|
||||
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) };
|
||||
f(exts).first().map(|s| json!(s))
|
||||
}
|
||||
Ask::Save(default) => {
|
||||
let Some(picked) = app.hooks.save_dialog(default) else { return Some(Err("no file dialog available (pass `path`)".into())) };
|
||||
picked.map(|s| json!(s))
|
||||
}
|
||||
};
|
||||
let Some(v) = picked else { return Some(Ok(Value::Null)) };
|
||||
Some(run_with_picked(app, id, key, params, v))
|
||||
}
|
||||
|
||||
/// The dialog's answer as the command's `key` parameter (`None`: cancelled).
|
||||
fn picked_value(key: &str, paths: Vec<String>) -> Option<Value> {
|
||||
match (paths.is_empty(), key) {
|
||||
(true, _) => None,
|
||||
(false, "paths") => Some(json!(paths)),
|
||||
(false, _) => paths.into_iter().next().map(Value::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file dialog shown without blocking ([`crate::Hooks::file_dialog_async`]) answered: run its
|
||||
/// command with the picked path (nothing when it was cancelled).
|
||||
pub(crate) fn dialog_answered(app: &mut EffectcraftApp, answer: crate::DialogAnswer) {
|
||||
let Some(v) = picked_value(answer.key, answer.paths) else { return };
|
||||
if let Err(e) = run_with_picked(app, &answer.command, answer.key, &answer.params, v) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run command `id` with the path picked for it in parameter `key`.
|
||||
fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, v: Value) -> Result<Value, String> {
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
p.insert(key.to_string(), v);
|
||||
// Picked files import in the background, with the Importing card showing progress (#270).
|
||||
if matches!(id, "file.import" | "file.importMultiple") {
|
||||
p.insert("background".into(), Value::Bool(true));
|
||||
}
|
||||
// Photoshop files ask how to import them first; numbered stills whether to import them as an
|
||||
// image sequence.
|
||||
let picked = Value::Object(p.clone());
|
||||
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked)
|
||||
|| matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked)
|
||||
{
|
||||
return Ok(json!({"dialog": id}));
|
||||
}
|
||||
// Save (untitled) becomes Save As.
|
||||
let id = if id == "file.save" { "file.saveAs" } else { id };
|
||||
let r = app.session.execute(id, Value::Object(p)).map_err(|e| e.to_string());
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// View ▸ Zoom In / Zoom Out: the next standard magnification (as a share of screen pixels, so
|
||||
/// 100 % is one comp pixel per screen pixel at any display scaling) above or below `cur`, which is
|
||||
/// in points per comp pixel like the result (#882).
|
||||
pub(super) fn zoom_step(cur: f32, ppp: f32, zoom_in: bool) -> f32 {
|
||||
const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0];
|
||||
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
let shown = cur * ppp;
|
||||
let next = if zoom_in {
|
||||
STEPS.iter().copied().find(|s| *s > shown + 1e-3).unwrap_or(32.0)
|
||||
} else {
|
||||
STEPS.iter().rev().copied().find(|s| *s < shown - 1e-3).unwrap_or(0.015)
|
||||
};
|
||||
next / ppp
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
use super::frontend::{file_dialog, frontend, run_engine};
|
||||
use super::shortcuts::system_paste_id;
|
||||
use super::*;
|
||||
|
||||
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<u64> = 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<u64> = 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<u64> = 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<u64> {
|
||||
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<Value, String> {
|
||||
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<effectcraft_engine::project::ItemId> {
|
||||
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<Value, String> {
|
||||
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;
|
||||
}
|
||||
// The Project-panel focus redirects apply only while no text edit is open: creating a text
|
||||
// layer leaves focus on Project while the insertion point lives in the comp, so during an
|
||||
// edit these commands address the text context — redirected, Deselect All would clear a
|
||||
// project selection the user is not looking at and silently swallow the edit's own end.
|
||||
if no_params(¶ms) && app.ui.focused == PanelKind::Project && app.session.state.text_edit.is_none() {
|
||||
match id {
|
||||
"edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})),
|
||||
"edit.selectAll" => {
|
||||
let items: Vec<u64> = 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::<String>(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<u64>, Option<u64>)>(egui::Id::unique("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 = app.now();
|
||||
// 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);
|
||||
}
|
||||
// New 3D Primitive without parameters opens its dialog.
|
||||
if crate::panels::primitive_dialog::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 ▸ <style> from the menu adds the style and opens the dialog on it.
|
||||
if crate::panels::layer_styles_dialog::route(app, id, ¶ms)? {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Shared docking: `window.dockAction`, craft-ui's `window.panel.*` commands, `ui.dock`,
|
||||
// `ui.dock.reset` and `window.panelWindow`.
|
||||
if let Some(result) = crate::dock_ui::route(app, ctx, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
// Legacy per-panel / per-workspace ids (`window.panel.Project`, `window.workspace.default`).
|
||||
if let Some(rest) = id.strip_prefix("window.panel.") {
|
||||
return frontend(app, ctx, "window.panel", json!({"panel": rest}));
|
||||
}
|
||||
if let Some(ws) = id.strip_prefix("window.workspace.") {
|
||||
if ws == "reset" {
|
||||
return frontend(app, ctx, "window.resetWorkspace", json!({}));
|
||||
}
|
||||
return frontend(app, ctx, "window.workspace", json!({"name": ws}));
|
||||
}
|
||||
if let Some(t) = id.strip_prefix("tool.") {
|
||||
// C cycles through the camera tools.
|
||||
if t == "camera" {
|
||||
let tools = [Tool::Orbit, Tool::PanCamera, Tool::Dolly];
|
||||
let next = match tools.iter().position(|x| *x == app.ui.tool) {
|
||||
Some(i) => tools[(i + 1) % tools.len()],
|
||||
None => Tool::Orbit,
|
||||
};
|
||||
app.ui.tool = next;
|
||||
return Ok(json!({"tool": next}));
|
||||
}
|
||||
// Slot shortcuts cycle through the slot's tools.
|
||||
let slot = match t {
|
||||
"shape" => Some(8),
|
||||
"pen" => Some(9),
|
||||
"type" => Some(10),
|
||||
"brush" => Some(11),
|
||||
"rotoBrush" => Some(14),
|
||||
"puppet" => Some(15),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(si) = slot {
|
||||
let tools: Vec<Tool> = match si {
|
||||
11 => vec![Tool::Brush, Tool::Clone, Tool::Eraser],
|
||||
_ => crate::state::Tool::SLOTS[si].to_vec(),
|
||||
};
|
||||
let cur = tools.iter().position(|x| *x == app.ui.tool);
|
||||
let next = match cur {
|
||||
Some(i) => tools[(i + 1) % tools.len()],
|
||||
None => app.ui.slot_tools.get(si).copied().filter(|x| tools.contains(x)).unwrap_or(tools[0]),
|
||||
};
|
||||
app.ui.tool = next;
|
||||
if si < app.ui.slot_tools.len() && si != 11 {
|
||||
app.ui.slot_tools[si] = next;
|
||||
}
|
||||
return Ok(json!({"tool": next}));
|
||||
}
|
||||
let tool = Tool::from_name(t).ok_or_else(|| format!("unknown tool `{t}`"))?;
|
||||
app.ui.tool = tool;
|
||||
return Ok(json!({"tool": tool}));
|
||||
}
|
||||
if let Some(dir) = id.strip_prefix("viewer.nudge.") {
|
||||
return nudge(app, ctx, dir);
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.reveal.") {
|
||||
if k == "animated" {
|
||||
return run_engine(app, ctx, "anim.reveal", json!({"kind": "keyframes"}));
|
||||
}
|
||||
reveal(app, k, now, false);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.keyAt.") {
|
||||
let r = run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}))?;
|
||||
reveal_keyed(app, &r, k);
|
||||
return Ok(r);
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.revealAdd.") {
|
||||
reveal(app, k, now, true);
|
||||
return Ok(json!({"revealed": app.ui.timeline.reveal}));
|
||||
}
|
||||
// U reveals keyframed properties; UU (pressed twice quickly) all modified properties; U
|
||||
// again later hides them.
|
||||
if id == "anim.reveal" && params.get("kind").and_then(Value::as_str) == Some("keyframes") {
|
||||
let last = app.last_reveal.take();
|
||||
app.last_reveal = Some(("keyframes".into(), now));
|
||||
match last {
|
||||
Some((k, t)) if k == "keyframes" && now - t < 0.6 => {
|
||||
return run_engine(app, ctx, "anim.reveal", json!({"kind": "modified"}));
|
||||
}
|
||||
Some((k, _)) if k == "keyframes" && app.ui.timeline.reveal == ["props"] => {
|
||||
let targets = reveal_targets(app);
|
||||
app.ui.timeline.apply_reveal(&targets, vec![]);
|
||||
return Ok(json!({"revealed": 0}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A UI-only command (not in the engine's menus), so `frontend` gets it from here.
|
||||
if id == "window.maximizeApp" {
|
||||
return frontend(app, ctx, id, params);
|
||||
}
|
||||
// Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}.
|
||||
if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) {
|
||||
let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`")));
|
||||
match op {
|
||||
"maximizePanel" => {
|
||||
let p = match panel_of("panel") {
|
||||
Some(p) => p?,
|
||||
None => app.panel_at(ctx.pointer_hover_pos()),
|
||||
};
|
||||
app.toggle_maximize(p);
|
||||
return Ok(json!({"maximized": app.ui.maximized}));
|
||||
}
|
||||
"closePanel" => {
|
||||
let p = panel_of("panel").ok_or("closePanel: need `panel`")??;
|
||||
if app.ui.dock.is_open(p) {
|
||||
app.apply_dock(craft_ui::docking::Action::Close { panel: p })?;
|
||||
}
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"floatPanel" => {
|
||||
let p = panel_of("panel").ok_or("floatPanel: need `panel`")??;
|
||||
let r = match params.get("rect") {
|
||||
Some(value) => serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?,
|
||||
None => crate::dock_ui::default_float_rect(ctx.content_rect()),
|
||||
};
|
||||
app.apply_dock(craft_ui::docking::Action::Float { panel: p, rect: r }).map_err(|e| format!("can't undock {}: {e}", p.title()))?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
_ => {
|
||||
// craft-ui's `window.panel.dock`: zones center/tab, left/right/top/bottom (a split
|
||||
// beside the anchor), above/below; without an anchor a side zone docks along that
|
||||
// edge of the whole workspace.
|
||||
let p = panel_of("panel").ok_or("dockPanel: need `panel`")??;
|
||||
let mut command = json!({"command": "window.panel.dock", "panel": p});
|
||||
if let Some(anchor) = panel_of("anchor") {
|
||||
command["anchor"] = json!(anchor?);
|
||||
command["zone"] = json!("center");
|
||||
}
|
||||
if let Some(zone) = params.get("zone") {
|
||||
let zone = zone.as_str().ok_or("zone: center|tab|left|right|top|bottom|above|below")?.to_ascii_lowercase();
|
||||
if command.get("anchor").is_some() || zone != "center" {
|
||||
command["zone"] = json!(zone);
|
||||
}
|
||||
}
|
||||
if let Some(before) = panel_of("before") {
|
||||
command["before"] = json!(before?);
|
||||
}
|
||||
let command: craft_ui::docking::DockCommand<PanelKind> =
|
||||
serde_json::from_value(command).map_err(|e| format!("dockPanel: zone: center|tab|left|right|top|bottom|above|below ({e})"))?;
|
||||
app.run_dock_command(ctx, command).map_err(|e| format!("can't dock {}: {e}", p.title()))?;
|
||||
app.show_panel(p);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
if id == "markers.dialog" {
|
||||
// The Composition/Layer Marker dialog (double-click a marker): {layer?, index}.
|
||||
let index = params.get("index").and_then(Value::as_u64).ok_or("markers.dialog: need `index`")? as usize;
|
||||
let layer = params.get("layer").and_then(Value::as_u64);
|
||||
crate::panels::markers_ui::open_dialog(app, crate::panels::markers_ui::MarkerRef { layer, index })?;
|
||||
return Ok(json!({"dialog": id}));
|
||||
}
|
||||
|
||||
if id == "view.theme.toggle" {
|
||||
app.cycle_appearance(ctx);
|
||||
return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode}));
|
||||
}
|
||||
if let Some(th) = id.strip_prefix("view.theme.") {
|
||||
let k = crate::theme::ThemeKind::from_name(th).ok_or("unknown theme")?;
|
||||
app.set_theme(ctx, k);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let timing = |app: &mut EffectcraftApp, op: &str| app.session.execute("layer.timing", json!({"op": op})).map_err(|e| e.to_string());
|
||||
match id {
|
||||
"playback.ramPreview" => {
|
||||
app.toggle_play_with(now, effectcraft_engine::preview::PreviewShortcut::Numpad0);
|
||||
return Ok(json!({"playing": app.playback.playing}));
|
||||
}
|
||||
"playback.preview.shiftSpacebar" | "playback.preview.shiftNumpad0" | "playback.preview.altNumpad0" => {
|
||||
let sc = effectcraft_engine::preview::PreviewShortcut::parse(id.trim_start_matches("playback.preview.")).ok_or("unknown preview shortcut")?;
|
||||
app.toggle_play_with(now, sc);
|
||||
return Ok(json!({"playing": app.playback.playing}));
|
||||
}
|
||||
"playback.stop" => {
|
||||
app.stop();
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// The focused Layer panel has its own magnification; otherwise the Composition viewer.
|
||||
"view.fit" if app.ui.focused == PanelKind::Layer => {
|
||||
app.ui.layer_zoom = None;
|
||||
app.ui.layer_pan = [0.0, 0.0];
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"view.fit" => {
|
||||
app.ui.viewer.zoom = None;
|
||||
app.ui.viewer.fit_up_to_100 = false;
|
||||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"timeline.scrollToTime" => app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Time),
|
||||
"timeline.scrollToLayer" => app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Layer),
|
||||
"timeline.parentColumn" => {
|
||||
let on = !crate::panels::timeline::column_visible(&app.ui.timeline, "parent");
|
||||
crate::panels::timeline::set_column(&mut app.ui.timeline, "parent", on)?;
|
||||
return Ok(json!({"parent": on}));
|
||||
}
|
||||
"view.fitUpTo100" => {
|
||||
app.ui.viewer.zoom = None;
|
||||
app.ui.viewer.fit_up_to_100 = true;
|
||||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Shift+, / Shift+.: the previous / next open composition (the viewer's tabs).
|
||||
"view.nextItem" | "view.previousItem" => {
|
||||
let open = app.session.state.open_comps.clone();
|
||||
let Some(i) = app.session.state.active_comp.and_then(|c| open.iter().position(|o| *o == c)) else { return Ok(Value::Null) };
|
||||
let step = if id == "view.nextItem" { 1 } else { open.len().saturating_sub(1) };
|
||||
let Some(next) = open.get((i + step) % open.len().max(1)).copied().filter(|n| Some(*n) != app.session.state.active_comp) else {
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
return run_engine(app, ctx, "comp.open", json!({"comp": next.0}));
|
||||
}
|
||||
// \\: between the Composition viewer and the Timeline.
|
||||
"window.toggleCompTimeline" => {
|
||||
let to = if app.ui.focused == PanelKind::Timeline { crate::panels::viewers::active_panel(app) } else { PanelKind::Timeline };
|
||||
app.show_panel(to);
|
||||
return Ok(json!({"focused": to.id()}));
|
||||
}
|
||||
// Alt+Shift+, / Alt+Shift+.: the previous / next tab of the focused panel's group.
|
||||
"window.nextPanel" | "window.previousPanel" => {
|
||||
let focused = app.ui.focused;
|
||||
let group = app.ui.dock.group_of(focused);
|
||||
let Some(i) = group.iter().position(|p| *p == focused) else { return Ok(Value::Null) };
|
||||
let step = if id == "window.nextPanel" { 1 } else { group.len().saturating_sub(1) };
|
||||
if let Some(next) = group.get((i + step) % group.len().max(1)).copied() {
|
||||
app.show_panel(next);
|
||||
}
|
||||
return Ok(json!({"focused": app.ui.focused.id()}));
|
||||
}
|
||||
// Ctrl+Shift+W: with a viewer or the Timeline active, every open composition closes;
|
||||
// any other panel closes itself.
|
||||
"window.closeAll" => {
|
||||
let focused = app.ui.focused;
|
||||
if matches!(focused, PanelKind::Composition | PanelKind::Timeline | PanelKind::Viewer(_)) {
|
||||
for c in app.session.state.open_comps.clone() {
|
||||
run_engine(app, ctx, "comp.close", json!({"comp": c.0}))?;
|
||||
}
|
||||
} else {
|
||||
app.close_panel(focused);
|
||||
}
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"view.actualSize" if app.ui.focused == PanelKind::Layer => {
|
||||
app.ui.layer_zoom = Some(1.0 / ctx.pixels_per_point());
|
||||
app.ui.layer_pan = [0.0, 0.0];
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"view.actualSize" => {
|
||||
app.ui.viewer.zoom = Some(1.0 / ctx.pixels_per_point());
|
||||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins,
|
||||
"view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid,
|
||||
"view.fastPreviews" => {
|
||||
// With a mode: Fast Previews ▸ Off / Adaptive Resolution / Draft / Fast Draft /
|
||||
// Wireframe; without: toggle Draft quality (Settings ▸ Previews).
|
||||
if params.get("mode").is_some() {
|
||||
return run_engine(app, ctx, "view.fastPreviewMode", params);
|
||||
}
|
||||
let on = !app.ui.viewer.fast_preview;
|
||||
app.set_pref("previews.fastPreviews", json!(on))?;
|
||||
app.ui.viewer.fast_preview = on;
|
||||
}
|
||||
"timeline.zoomIn" | "timeline.zoomOut" => {
|
||||
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);
|
||||
}
|
||||
"timeline.zoomFit" => crate::panels::timeline::toggle_fit_zoom(app, ctx),
|
||||
"timeline.zoomFrameToggle" => crate::panels::timeline::toggle_frame_zoom(app, ctx),
|
||||
"timeline.column" => {
|
||||
let col = params.get("column").and_then(Value::as_str).ok_or("timeline.column: need `column`")?;
|
||||
let on = params.get("visible").and_then(Value::as_bool).unwrap_or(!crate::panels::timeline::column_visible(&app.ui.timeline, col));
|
||||
crate::panels::timeline::set_column(&mut app.ui.timeline, col, on)?;
|
||||
let shown: Vec<&str> =
|
||||
crate::panels::timeline::COLUMNS.iter().map(|c| c.0).filter(|c| crate::panels::timeline::column_visible(&app.ui.timeline, c)).collect();
|
||||
return Ok(json!({"columns": shown}));
|
||||
}
|
||||
"timeline.sourceName" => {
|
||||
let tl = &mut app.ui.timeline;
|
||||
tl.source_name = params.get("value").and_then(Value::as_bool).unwrap_or(!tl.source_name);
|
||||
return Ok(json!({"sourceName": tl.source_name}));
|
||||
}
|
||||
"flowchart.options" | "flowchart.graph" => return crate::panels::flowchart::command(app, id, ¶ms),
|
||||
"timeline.search" => {
|
||||
app.ui.timeline.search = params.get("query").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
app.show_panel(PanelKind::Timeline);
|
||||
}
|
||||
"timeline.graphEditor" => app.ui.timeline.graph_editor = !app.ui.timeline.graph_editor,
|
||||
"timeline.switchesModes" => app.ui.timeline.show_modes = !app.ui.timeline.show_modes,
|
||||
"timeline.collapseAll" => {
|
||||
app.ui.timeline.open_layers.clear();
|
||||
app.ui.timeline.open_groups.clear();
|
||||
app.ui.timeline.reveal.clear();
|
||||
app.ui.timeline.layer_reveal.clear();
|
||||
}
|
||||
// Ctrl+`: twirl the selected layers open (all closed ones) or closed (all open). From a
|
||||
// reveal shortcut's view it opens their full property trees.
|
||||
"timeline.twirlSelected" => {
|
||||
let sel: Vec<u64> = app.session.state.selected_layers.iter().map(|l| l.0).collect();
|
||||
let tl = &mut app.ui.timeline;
|
||||
let revealing = sel.iter().any(|l| tl.layer_reveal.contains_key(l));
|
||||
tl.reveal.clear();
|
||||
for l in &sel {
|
||||
tl.layer_reveal.remove(l);
|
||||
}
|
||||
if !revealing && sel.iter().all(|l| tl.open_layers.contains(l)) {
|
||||
for l in &sel {
|
||||
tl.open_layers.remove(l);
|
||||
}
|
||||
} else {
|
||||
tl.open_layers.extend(sel);
|
||||
}
|
||||
}
|
||||
"timeline.workAreaBegin" => return app.session.execute("comp.workArea", json!({"set": "begin"})).map_err(|e| e.to_string()),
|
||||
"timeline.workAreaEnd" => return app.session.execute("comp.workArea", json!({"set": "end"})).map_err(|e| e.to_string()),
|
||||
"timeline.moveInToTime" => return timing(app, "moveInToTime"),
|
||||
"timeline.moveOutToTime" => return timing(app, "moveOutToTime"),
|
||||
"timeline.trimInToTime" => return timing(app, "trimInToTime"),
|
||||
"timeline.trimOutToTime" => return timing(app, "trimOutToTime"),
|
||||
"app.home" => app.ui.start_screen = !app.ui.start_screen,
|
||||
"app.newComp" | "comp.new" if no_params(¶ms) => crate::panels::dialogs::open_new_comp(app),
|
||||
"app.compSettings" | "comp.settings" if no_params(¶ms) => crate::panels::dialogs::open_comp_settings(app)?,
|
||||
"app.compSettings" | "comp.settings" if only_comp(¶ms).is_some() => {
|
||||
crate::panels::dialogs::open_comp_settings_for(app, only_comp(¶ms).ok_or("missing `comp`")?)?
|
||||
}
|
||||
"app.solidSettings" | "layer.newSolid" if no_params(¶ms) => crate::panels::dialogs::open_new_solid(app)?,
|
||||
"keys.velocity" if no_params(¶ms) => crate::panels::key_dialogs::open_velocity(app)?,
|
||||
"keys.interpolation" if no_params(¶ms) => crate::panels::key_dialogs::open_interpolation(app)?,
|
||||
"layer.timeStretch" if no_params(¶ms) => crate::panels::key_dialogs::open_time_stretch(app)?,
|
||||
_ => {
|
||||
if id == "renderQueue.render" && params.get("wait").is_none() {
|
||||
// The UI renders in the background; the panel shows progress.
|
||||
let mut p = params.clone();
|
||||
if let Some(m) = p.as_object_mut() {
|
||||
m.insert("wait".into(), json!(false));
|
||||
} else {
|
||||
p = json!({"wait": false});
|
||||
}
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
return app.session.execute(id, p).map_err(|e| e.to_string());
|
||||
}
|
||||
if id == "renderQueue.add" {
|
||||
let r = app.session.execute(id, params).map_err(|e| e.to_string());
|
||||
match &r {
|
||||
Ok(_) => app.show_panel(PanelKind::RenderQueue),
|
||||
Err(e) => app.ui.status = e.clone(),
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = file_dialog(app, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
if id == "layer.precompose" && params.get("name").is_none() {
|
||||
crate::panels::precomp::open(app, ¶ms)?;
|
||||
return Ok(json!({"dialog": id}));
|
||||
}
|
||||
if crate::panels::dialogs::open_form(app, id, ¶ms) {
|
||||
return Ok(json!({"dialog": id}));
|
||||
}
|
||||
if id == "renderQueue.render" && params.get("wait").is_none() {
|
||||
// The UI renders in the background; the panel shows progress.
|
||||
let mut p = params.clone();
|
||||
if let Some(m) = p.as_object_mut() {
|
||||
m.insert("wait".into(), json!(false));
|
||||
} else {
|
||||
p = json!({"wait": false});
|
||||
}
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
return app.session.execute(id, p).map_err(|e| e.to_string());
|
||||
}
|
||||
if id == "renderQueue.add" {
|
||||
let r = app.session.execute(id, params).map_err(|e| e.to_string());
|
||||
match &r {
|
||||
Ok(_) => app.show_panel(PanelKind::RenderQueue),
|
||||
Err(e) => app.ui.status = e.clone(),
|
||||
}
|
||||
return r;
|
||||
}
|
||||
// An effect added to a layer (Effect menu, Last Effect, dropped on a layer) brings
|
||||
// up Effect Controls on that layer, as in After Effects.
|
||||
if matches!(id, "effect.apply" | "effect.applyLast") {
|
||||
let r = run_engine(app, ctx, id, params.clone());
|
||||
if let Ok(applied) = &r {
|
||||
crate::panels::effect_controls::reveal_applied(app, ¶ms);
|
||||
crate::panels::effect_controls::choose_files_after_apply(app, applied);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
// Enabling time remapping twirls its layers open on Time Remap and its two keys.
|
||||
if id == "layer.enableTimeRemap" {
|
||||
let r = run_engine(app, ctx, id, params.clone());
|
||||
if r.as_ref().is_ok_and(|on| on.as_bool() == Some(true)) {
|
||||
reveal_time_remap(app, ¶ms);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if id == "layer.rename" && params.get("name").is_none() {
|
||||
crate::panels::timeline::begin_rename(app, ctx);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
return run_engine(app, ctx, id, params);
|
||||
}
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
use super::frontend::run_engine;
|
||||
use super::invoke::invoke;
|
||||
use super::*;
|
||||
|
||||
/// Shortcut text in this OS's notation (`⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere).
|
||||
pub fn shortcut_text(s: &str) -> String {
|
||||
if cfg!(target_os = "macos") {
|
||||
let mut out = String::new();
|
||||
let parts: Vec<&str> = match s.strip_suffix("++") {
|
||||
Some(head) => vec![head, "+"],
|
||||
None => s.split('+').collect(),
|
||||
};
|
||||
let (mods, key) = parts.split_at(parts.len().saturating_sub(1));
|
||||
for m in mods {
|
||||
out.push_str(match *m {
|
||||
"Ctrl" => "⌃",
|
||||
"Alt" => "⌥",
|
||||
"Shift" => "⇧",
|
||||
"Cmd" => "⌘",
|
||||
x => x,
|
||||
});
|
||||
}
|
||||
out.push_str(match key.first().copied().unwrap_or("") {
|
||||
"Space" => "Space",
|
||||
"Escape" => "Esc",
|
||||
"Home" => "↖",
|
||||
k => k,
|
||||
});
|
||||
out
|
||||
} else {
|
||||
s.replace("Cmd", "Ctrl")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse "Cmd+Shift+K" into modifiers + key.
|
||||
pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> {
|
||||
let mut m = egui::Modifiers::NONE;
|
||||
let mut key = None;
|
||||
// The plus key itself is written `+` or `Cmd++`.
|
||||
let (head, plus) = match s.strip_suffix("++") {
|
||||
Some(h) => (h, true),
|
||||
None if s == "+" => ("", true),
|
||||
None => (s, false),
|
||||
};
|
||||
let mut parts: Vec<&str> = if head.is_empty() { vec![] } else { head.split('+').collect() };
|
||||
if plus {
|
||||
parts.push("+");
|
||||
}
|
||||
for p in parts {
|
||||
match p {
|
||||
"Cmd" => {
|
||||
m.command = true;
|
||||
m.mac_cmd = cfg!(target_os = "macos");
|
||||
if !cfg!(target_os = "macos") {
|
||||
m.ctrl = true;
|
||||
}
|
||||
}
|
||||
"Physical" => {}
|
||||
"Shift" => m.shift = true,
|
||||
"Alt" => m.alt = true,
|
||||
"Ctrl" => m.ctrl = true,
|
||||
k => {
|
||||
key = match k {
|
||||
";" => Some(egui::Key::Semicolon),
|
||||
"'" => Some(egui::Key::Quote),
|
||||
"," => Some(egui::Key::Comma),
|
||||
"." => Some(egui::Key::Period),
|
||||
"/" => Some(egui::Key::Slash),
|
||||
"\\" => Some(egui::Key::Backslash),
|
||||
"=" => Some(egui::Key::Equals),
|
||||
"-" => Some(egui::Key::Minus),
|
||||
"`" => Some(egui::Key::Backtick),
|
||||
"[" => Some(egui::Key::OpenBracket),
|
||||
"]" => Some(egui::Key::CloseBracket),
|
||||
"Num0" => Some(egui::Key::Num0),
|
||||
"Num*" => None,
|
||||
_ => egui::Key::from_name(k),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
key.map(|k| (m, k))
|
||||
}
|
||||
|
||||
/// The key some keyboards report for a shifted punctuation key (`Shift+=` arrives as `+`).
|
||||
fn shifted_alias(k: egui::Key) -> Option<egui::Key> {
|
||||
use egui::Key::*;
|
||||
Some(match k {
|
||||
Equals => Plus,
|
||||
Semicolon => Colon,
|
||||
OpenBracket => OpenCurlyBracket,
|
||||
CloseBracket => CloseCurlyBracket,
|
||||
Slash => Questionmark,
|
||||
Backslash => Pipe,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A key binding: modifiers, key, command id and bound params.
|
||||
pub type Binding = (egui::Modifiers, egui::Key, String, Value);
|
||||
|
||||
/// All bindings of the active keyboard shortcut preset, most modifiers first.
|
||||
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") {
|
||||
continue;
|
||||
}
|
||||
if let Some((m, k)) = parse_shortcut(sc) {
|
||||
if m.shift
|
||||
&& let Some(alias) = shifted_alias(k)
|
||||
{
|
||||
v.push((m, alias, b.command.clone(), b.params.clone()));
|
||||
}
|
||||
v.push((m, k, b.command.clone(), b.params.clone()));
|
||||
}
|
||||
}
|
||||
v.sort_by_key(|(m, ..)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8));
|
||||
v
|
||||
}
|
||||
|
||||
fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool {
|
||||
want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.command && cfg!(not(target_os = "macos")))
|
||||
}
|
||||
|
||||
/// Whether the `key`+`mods` chord is the current binding of `command` in the active preset
|
||||
/// (so callers that must match a shortcut themselves — like the viewer's text editor, which
|
||||
/// holds the keyboard while the shortcut dispatcher is skipped — follow rebinds).
|
||||
/// `Physical+`-only bindings never match here, exactly as for the app-wide dispatcher.
|
||||
pub fn chord_matches(session: &effectcraft_engine::Session, command: &str, key: egui::Key, mods: egui::Modifiers) -> bool {
|
||||
bindings(session).into_iter().any(|(m, k, id, _)| id == command && k == key && mods_match(m, mods))
|
||||
}
|
||||
|
||||
/// Whether a Deselect All chord matched by [`chord_matches`] may run while a text editor holds
|
||||
/// the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
|
||||
/// such for undo and the rest), and function keys never type, so those forward from the editor;
|
||||
/// a chord that could instead produce a character — a bare or Shift-only letter, digit or
|
||||
/// punctuation key, as from a pathological bare-letter rebinding — stays with the editor so
|
||||
/// typing keeps working, the same invariant the app-wide dispatcher honors by skipping
|
||||
/// focused text fields.
|
||||
pub fn forwardable_during_text_edit(mods: egui::Modifiers, key: egui::Key) -> bool {
|
||||
mods.command || mods.ctrl || mods.alt || (egui::Key::F1..=egui::Key::F35).contains(&key)
|
||||
}
|
||||
|
||||
/// Set (egui temp data) from a plain Spacebar press until its release while a tap would still
|
||||
/// run the Spacebar shortcut.
|
||||
/// The system clipboard's text of this frame's paste event, for the Edit ▸ Paste it runs.
|
||||
pub(super) fn system_paste_id() -> egui::Id {
|
||||
egui::Id::unique("menus-system-paste")
|
||||
}
|
||||
|
||||
/// The temp-data id of the flag set by a plain Space press and taken on its release, so a tap
|
||||
/// runs the Space shortcut while holding Space stays the Hand tool.
|
||||
fn space_tap_id() -> egui::Id {
|
||||
egui::Id::unique("spacebar-tap")
|
||||
}
|
||||
|
||||
/// The modifiers held when this frame's input began: the state the last pass ended with, if it
|
||||
/// ran this (egui keeps only the state after the frame's events). Records this pass's state.
|
||||
pub(super) fn modifiers_at_start(ctx: &egui::Context) -> Option<egui::Modifiers> {
|
||||
let id = egui::Id::unique("shortcut-modifiers");
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
let now = ctx.input(|i| i.modifiers);
|
||||
ctx.data_mut(|d| {
|
||||
let last = d.get_temp::<(u64, egui::Modifiers, Option<egui::Modifiers>)>(id);
|
||||
if let Some((p, _, start)) = last
|
||||
&& p == pass
|
||||
{
|
||||
return start;
|
||||
}
|
||||
let start = last.and_then(|(p, m, _)| (p.checked_add(1) == Some(pass)).then_some(m));
|
||||
d.insert_temp(id, (pass, now, start));
|
||||
start
|
||||
})
|
||||
}
|
||||
|
||||
/// Only Shift held (Shift+Delete, not Ctrl/Alt+Shift+Delete).
|
||||
fn only_shift(m: egui::Modifiers) -> bool {
|
||||
m.shift && !m.alt && !m.ctrl && !m.command && !m.mac_cmd
|
||||
}
|
||||
|
||||
/// Dispatch keyboard shortcuts (skipped while typing in a text field).
|
||||
pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
handle_shortcuts_impl(app, ctx, false);
|
||||
}
|
||||
|
||||
/// Tab can acquire widget focus while the current frame is drawn. Dispatch its application
|
||||
/// binding only afterward, and only when no widget owned focus before or after layout.
|
||||
pub fn handle_tab_shortcut(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Tab)) {
|
||||
handle_shortcuts_impl(app, ctx, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) {
|
||||
let start = modifiers_at_start(ctx);
|
||||
// Dialog cancellation owns Escape even when a text field has keyboard focus.
|
||||
if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
// A popup or colour picker takes Escape first (the picker goes back to its original colour).
|
||||
if crate::panels::shortcut_editor::recording(app)
|
||||
|| crate::panels::shortcut_editor::confirming(app)
|
||||
|| egui::Popup::is_any_open(ctx)
|
||||
|| crate::color_picker::open_recently(ctx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
|
||||
if app.dialog == Some(crate::Dialog::LayerStyles) {
|
||||
crate::panels::layer_styles_dialog::finish(app, false);
|
||||
} else if app.dialog == Some(crate::Dialog::Info) {
|
||||
crate::panels::forms::close_info(app);
|
||||
} else {
|
||||
if app.dialog == Some(crate::Dialog::Settings)
|
||||
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
|
||||
{
|
||||
app.session.prefs = p;
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
if app.dialog == Some(crate::Dialog::UnsavedChanges) {
|
||||
app.dialog_state.unsaved.command = None;
|
||||
}
|
||||
app.dialog = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A mouse button down while Spacebar is held belongs to the Hand tool: its release won't
|
||||
// preview.
|
||||
if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() {
|
||||
ctx.data_mut(|d| d.remove::<bool>(space_tap_id()));
|
||||
}
|
||||
// While a menu or popup list is open, keys go to it, not to the app (Space must not start a
|
||||
// preview behind the menu, #279).
|
||||
if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) {
|
||||
return;
|
||||
}
|
||||
// Text editing in the viewer takes the clipboard events itself.
|
||||
let clipboard = app.session.state.text_edit.is_none();
|
||||
let project = app.ui.focused == PanelKind::Project;
|
||||
// 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, 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
|
||||
// `i.modifiers` is the state after all of the frame's events, so each is read with the
|
||||
// modifiers where it is in the stream: walk the events from the frame's starting state.
|
||||
// `None` when that isn't known.
|
||||
let changed = i.events.iter().any(|e| matches!(e, egui::Event::ModifiersChanged(_) | egui::Event::WindowFocused(false)));
|
||||
let mut at = if changed { start } else { Some(i.modifiers) };
|
||||
let held = |at: Option<egui::Modifiers>| at.filter(|m| m.command).unwrap_or(egui::Modifiers::COMMAND);
|
||||
let mut out = Vec::new();
|
||||
for e in &i.events {
|
||||
match e {
|
||||
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, 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, *physical_key));
|
||||
}
|
||||
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, None));
|
||||
}
|
||||
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);
|
||||
// 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 == "*"))) {
|
||||
let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone()));
|
||||
// As in After Effects, it marks the selected layers; only with none selected does it add
|
||||
// a composition marker (#976).
|
||||
let bound = bound.map(|(id, params)| {
|
||||
if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) }
|
||||
});
|
||||
if let Some((id, params)) = bound
|
||||
&& let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params })
|
||||
{
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let binds = bindings(&app.session);
|
||||
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()) {
|
||||
if pressed {
|
||||
if app.dialog.is_none() {
|
||||
ctx.data_mut(|d| d.insert_temp(space_tap_id(), true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !ctx.data_mut(|d| d.remove_temp::<bool>(space_tap_id())).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Escape closes dialogs.
|
||||
if key == egui::Key::Escape && app.dialog.is_some() {
|
||||
// Escape while recording a shortcut cancels the recording, not the editor; so does
|
||||
// Escape in its Delete Preset prompt.
|
||||
if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) {
|
||||
continue;
|
||||
}
|
||||
if app.dialog == Some(crate::Dialog::Info) {
|
||||
crate::panels::forms::close_info(app);
|
||||
continue;
|
||||
}
|
||||
// Escape in Settings is Cancel: restore the settings from when it opened.
|
||||
if app.dialog == Some(crate::Dialog::Settings)
|
||||
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
|
||||
{
|
||||
app.session.prefs = p;
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
app.dialog = None;
|
||||
continue;
|
||||
}
|
||||
if app.dialog.is_some() {
|
||||
continue;
|
||||
}
|
||||
// Enter in the Project panel renames the selected item (handled by the panel).
|
||||
if key == egui::Key::Enter && !mods.any() && app.ui.focused == PanelKind::Project {
|
||||
continue;
|
||||
}
|
||||
// The Project panel has its own selection; a previously selected Timeline layer
|
||||
// must not be deleted while the Project panel has focus.
|
||||
if matches!(key, egui::Key::Delete | egui::Key::Backspace) && !mods.any() {
|
||||
if matches!(app.ui.focused, PanelKind::Composition | PanelKind::Viewer(_)) && !app.session.state.camera_points.is_empty() {
|
||||
if let Err(e) = run_engine(app, ctx, "camera.deletePoints", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key));
|
||||
continue;
|
||||
}
|
||||
// The queue panel consumes the event after its selection state is drawn.
|
||||
if app.ui.focused == PanelKind::RenderQueue {
|
||||
continue;
|
||||
}
|
||||
let _ = invoke(app, ctx, "edit.clear", json!({}));
|
||||
continue;
|
||||
}
|
||||
// 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
|
||||
{
|
||||
ctx.data_mut(|d| d.insert_temp(system_paste_id(), t.clone()));
|
||||
}
|
||||
if let Err(e) = invoke(app, ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
ctx.data_mut(|d| d.remove_temp::<String>(system_paste_id()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user