UI-8, UI-3: native macOS menu bar; timeline columns, search, reveal-add, UU, CTI snapping

Native menu bar (apps/effectcraft, muda, macOS only): the engine's After Effects
menu tree mirrored into NSMenu through a pure, serializable spec
(ui-egui native_menu: submenus, separators, live labels, check marks, enabled
state, Cmd/Ctrl/function-key shortcuts as accelerators, Open Recent). Hide /
Hide Others / Show All are macOS' predefined items (app.hideOthers/app.showAll
are real commands now, via NSApplication); Services added; About, Settings and
Quit dispatch our commands. State is pushed only when the session revision,
selection, history, settings or journal change; structural changes rebuild.
Activations dispatch the same command ids as the in-window bar; Edit
Cut/Copy/Paste/Select All/Undo/Redo act on a focused text field. Settings >
Appearance > Use In-Window Menu Bar on macOS switches back.

Timeline: column visibility from the column header's context menu (A/V
Features, Keys, Label, #, Comment, Switches, Modes, Parent & Link, In, Out,
Duration, Stretch; Source/Layer Name toggle), Comment editing
(layer.setComment), draggable In/Out/Duration cells, Stretch opens Time
Stretch; property search filters layers and reveals matching properties with
their groups; Shift+P/S/R/T/A/E/M/F/L/U add to the revealed properties; UU
reveals all modified properties; Shift-drag in the ruler snaps the CTI to keys,
in/out points, markers and the work area. Commands: timeline.column,
timeline.sourceName, timeline.search, timeline.revealAdd.*.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 22:21:08 -04:00
1 parent 758c3cebd4
commit 8404fead60
13 files changed
+1640 -206

No files matched your search

Generated
+42
View File
@@ -971,6 +971,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -1133,6 +1142,10 @@ dependencies = [
"effectcraft-ui-egui",
"eframe",
"egui",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"rfd",
"serde_json",
@@ -2619,6 +2632,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -2826,6 +2849,25 @@ dependencies = [
"pxfm",
]
[[package]]
name = "muda"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
+6
View File
@@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
serde_json = { workspace = true }
rfd = { workspace = true }
cpal = { workspace = true }
log = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows.
@@ -28,3 +29,8 @@ winresource = "0.1"
winit = { version = "0.30", default-features = false }
# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] }
# The native menu bar (NSMenu). MIT OR Apache-2.0; macOS only, never in the web build.
muda = { version = "0.21", default-features = false }
objc2 = "0.6"
# NSApplication hide / hide others / show all, and the pasteboard for Edit ▸ Paste in text fields.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSPasteboard"] }
+39 -1
View File
@@ -7,6 +7,8 @@
mod audio_out;
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
@@ -100,16 +102,52 @@ fn main() -> eframe::Result {
app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
#[cfg(target_os = "macos")]
{
app.hooks.app_action = Some(Box::new(native_menu::app_action));
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
}
if let Some(port) = control_port {
disable_app_nap();
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
Ok(Box::new(app))
Ok(Box::new(Desktop {
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar::new(&cc.egui_ctx),
app,
}))
}),
)
}
/// The desktop app: the egui app plus, on macOS, the system menu bar.
struct Desktop {
app: EffectcraftApp,
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar,
}
impl eframe::App for Desktop {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
self.menu.update(&mut self.app, ctx);
self.app.logic(ctx, frame);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
}
fn on_exit(&mut self) {
self.app.on_exit();
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw_input);
}
}
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
+210
View File
@@ -0,0 +1,210 @@
//! macOS: the menu bar in the system menu bar (`NSMenu` through `muda`), built from
//! [`effectcraft_ui_egui::native_menu`]'s spec of the engine's After Effects menu tree.
//!
//! Each frame [`NativeBar::update`] runs menu activations (the same command ids as the in-window
//! bar), and when [`native_menu::state_key`] changed it rebuilds the spec and pushes labels,
//! enabled and check state into the existing items; a structural change (Open Recent, a new
//! shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS
//! swaps back to the in-window bar.
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, channel};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::native_menu::{self, NativeMenu, NativeNode, Role};
use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
/// The `muda` accelerator for a spec accelerator string.
pub fn parse_accelerator(s: &str) -> Option<muda::accelerator::Accelerator> {
s.parse().ok()
}
pub struct NativeBar {
menu: Option<Menu>,
items: HashMap<String, CheckMenuItem>,
spec: NativeMenu,
structure: u64,
state: Option<u64>,
events: Receiver<MenuEvent>,
/// The in-window-bar setting last applied (None: not yet).
in_window: Option<bool>,
}
impl NativeBar {
pub fn new(ctx: &egui::Context) -> Self {
let (tx, rx) = channel();
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
let _ = tx.send(e);
ctx.request_repaint();
}));
NativeBar { menu: None, items: HashMap::new(), spec: NativeMenu::default(), structure: 0, state: None, events: rx, in_window: None }
}
/// Run activations and keep the native menu in step with the app (call once per frame).
pub fn update(&mut self, app: &mut EffectcraftApp, ctx: &egui::Context) {
let in_window = app.session.prefs.appearance.in_window_menu_bar_mac;
if self.in_window != Some(in_window) {
self.in_window = Some(in_window);
app.ui.show_menu_bar = in_window;
if in_window {
if let Some(m) = self.menu.take() {
m.remove_for_nsapp();
}
self.items.clear();
} else {
self.state = None;
self.structure = 0;
}
}
while let Ok(e) = self.events.try_recv() {
if let Err(err) = native_menu::activate(app, ctx, &self.spec, &e.id.0) {
log::warn!("menu: {err}");
}
// Check items toggle themselves when clicked: resync with the app.
self.state = None;
}
if in_window {
return;
}
let key = native_menu::state_key(app);
if self.state == Some(key) && self.menu.is_some() {
return;
}
self.state = Some(key);
let spec = native_menu::build(app);
let structure = spec.structure();
if self.menu.is_none() || structure != self.structure {
self.install(&spec);
self.structure = structure;
} else {
self.refresh(&spec);
}
self.spec = spec;
}
fn install(&mut self, spec: &NativeMenu) {
if let Some(m) = self.menu.take() {
m.remove_for_nsapp();
}
self.items.clear();
let menu = Menu::new();
for node in &spec.menus {
if let NativeNode::Submenu { label, children } = node {
let sub = Submenu::new(label, true);
self.fill(&sub, children);
match label.as_str() {
"Window" => sub.set_as_windows_menu_for_nsapp(),
"Help" => sub.set_as_help_menu_for_nsapp(),
_ => {}
}
if let Err(e) = menu.append(&sub) {
log::warn!("menu {label}: {e}");
}
}
}
menu.init_for_nsapp();
self.menu = Some(menu);
}
fn fill(&mut self, parent: &Submenu, nodes: &[NativeNode]) {
for n in nodes {
let r = match n {
NativeNode::Separator => parent.append(&PredefinedMenuItem::separator()),
NativeNode::Predefined { role, label, .. } => {
let item = match role {
Role::Services => PredefinedMenuItem::services(Some(label)),
Role::Hide => PredefinedMenuItem::hide(Some(label)),
Role::HideOthers => PredefinedMenuItem::hide_others(Some(label)),
Role::ShowAll => PredefinedMenuItem::show_all(Some(label)),
};
parent.append(&item)
}
NativeNode::Submenu { label, children } => {
let sub = Submenu::new(label, true);
self.fill(&sub, children);
parent.append(&sub)
}
NativeNode::Item(i) => {
let accel = i.accelerator.as_deref().and_then(parse_accelerator);
let item = CheckMenuItem::with_id(i.id.clone(), &i.label, i.enabled, i.checked == Some(true), accel);
let r = parent.append(&item);
self.items.insert(i.id.clone(), item);
r
}
};
if let Err(e) = r {
log::warn!("menu item: {e}");
}
}
}
/// Push label / enabled / check changes into the existing items.
fn refresh(&self, spec: &NativeMenu) {
let old: HashMap<&str, &native_menu::NativeItem> = self.spec.items().into_iter().map(|i| (i.id.as_str(), i)).collect();
for i in spec.items() {
let Some(native) = self.items.get(&i.id) else { continue };
let prev = old.get(i.id.as_str());
if prev.is_none_or(|p| p.label != i.label) {
native.set_text(&i.label);
}
if prev.is_none_or(|p| p.enabled != i.enabled) {
native.set_enabled(i.enabled);
}
let checked = i.checked == Some(true);
if native.is_checked() != checked {
native.set_checked(checked);
}
}
}
}
/// `app.hide` / `app.hideOthers` / `app.showAll` through NSApplication (main thread only).
pub fn app_action(id: &str) -> bool {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
let Some(mtm) = MainThreadMarker::new() else { return false };
let app = NSApplication::sharedApplication(mtm);
match id {
"app.hide" => app.hide(None),
"app.hideOthers" => app.hideOtherApplications(None),
"app.showAll" => app.unhideAllApplications(None),
_ => return false,
}
true
}
/// The general pasteboard's text.
pub fn clipboard_text() -> Option<String> {
use objc2_app_kit::NSPasteboard;
// NSPasteboardTypeString's value (the UTI), without touching the extern static.
let ty = objc2_foundation::NSString::from_str("public.utf8-plain-text");
NSPasteboard::generalPasteboard().stringForType(&ty).map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn every_accelerator_parses_with_muda() {
let mut s = effectcraft_engine::Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let app = EffectcraftApp::new(s);
let spec = native_menu::build(&app);
let mut n = 0;
for i in spec.items() {
if let Some(a) = &i.accelerator {
assert!(parse_accelerator(a).is_some(), "{} `{a}` does not parse", i.label);
n += 1;
}
}
assert!(n > 50, "only {n} accelerators");
// Spot checks against the AE shortcuts.
let accel = |cmd: &str| spec.items().into_iter().find(|i| i.command == cmd).and_then(|i| i.accelerator.clone());
assert_eq!(accel("file.saveAs").as_deref(), Some("Cmd+Shift+S"));
assert_eq!(accel("comp.new").as_deref(), Some("Cmd+N"));
assert_eq!(parse_accelerator("Cmd+Shift+S"), "CmdOrCtrl+Shift+S".parse().ok());
}
}
+3
View File
@@ -26,6 +26,9 @@ pub fn specs() -> Vec<CommandSpec> {
),
fe!("app.gpuInfo", "GPU Information...", [], None, "{}", always),
fe!("app.hide", "Hide EffectCraft", [], None, "{}", always),
// macOS: the native menu's Hide Others / Show All (the desktop app hides the other apps).
fe!("app.hideOthers", "Hide Others", [], None, "{}", always),
fe!("app.showAll", "Show All", [], None, "{}", always),
fe!("app.quit", "Quit EffectCraft", [], Some("Cmd+Q"), "{}", always),
fe!("app.commandPalette", "Quick Apply...", ["Edit"], Some("Cmd+Shift+Space"), "{query?}", always),
fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always),
+15
View File
@@ -433,6 +433,20 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// The Timeline's Comment column.
fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string();
s.edit("Layer Comment", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.comment = text.clone();
}
Ok(())
})?;
Ok(Value::Null)
}
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let mode = str_p(p, "mode").and_then(BlendMode::from_name);
@@ -1073,6 +1087,7 @@ pub fn specs() -> Vec<CommandSpec> {
set_switch
),
cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename),
cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment),
cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend),
cmd!(
"layer.setTrackMatte",
-3
View File
@@ -17,9 +17,6 @@ macro_rules! stub {
pub fn specs() -> Vec<CommandSpec> {
vec![
// macOS application menu items handled by the OS.
stub!("app.hideOthers", "Hide Others", [], "{}"),
stub!("app.showAll", "Show All", [], "{}"),
// Render queue / export (export milestone).
stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"),
// Proxies (no proxy model yet).
+4
View File
@@ -115,6 +115,8 @@ page!(Appearance {
use_label_color_for_tabs: bool = true,
cycle_mask_colors: bool = true,
use_gradients: bool = true,
/// macOS: draw the menu bar inside the window instead of the system menu bar.
in_window_menu_bar_mac: bool = false,
});
page!(Grids {
@@ -740,6 +742,8 @@ pub fn pages() -> Vec<Page> {
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, false),
s("appearance.cycleMaskColors", "Cycle Mask Colors", B, false),
s("appearance.useGradients", "Use Gradients", B, false),
Section("Menu Bar"),
s("appearance.inWindowMenuBarMac", "Use In-Window Menu Bar on macOS", B, true),
],
},
Page {
+6
View File
@@ -14,6 +14,7 @@ pub mod frames;
pub mod header;
pub mod icons;
pub mod menus;
pub mod native_menu;
pub mod panels;
pub mod state;
pub mod theme;
@@ -86,6 +87,11 @@ pub struct Hooks {
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
/// Save dialog for other file kinds: (default name, extension).
pub pick_save_file: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
/// The system clipboard's text (native menu Edit ▸ Paste into a text field).
pub clipboard_text: Option<Box<dyn Fn() -> Option<String>>>,
/// Application actions the OS performs (`app.hide`, `app.hideOthers`, `app.showAll` on
/// macOS). Returns false when the host doesn't handle the id.
pub app_action: Option<Box<dyn Fn(&str) -> bool>>,
}
#[derive(Default)]
+94 -19
View File
@@ -59,6 +59,19 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("timeline.reveal.feather", "Reveal Mask Feather", [], Some("F")),
uic!("timeline.reveal.levels", "Reveal Audio Levels (press twice: Waveform)", [], Some("L")),
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!("timeline.collapseAll", "Collapse All", [], Some("Cmd+`")),
uic!("tool.selection", "Selection Tool", [], Some("V")),
uic!("tool.hand", "Hand Tool", [], Some("H")),
@@ -89,26 +102,42 @@ pub fn panel_command_id(p: PanelKind) -> String {
format!("window.panel.{}", p.id())
}
fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64) {
/// 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) {
// AE's double-press shortcuts: L then L again quickly reveals the Waveform (LL).
let kind = match app.last_reveal.take() {
Some((k, t)) if k == "levels" && kind == "levels" && now - t < 0.6 => "waveform",
_ => kind,
};
app.last_reveal = Some((kind.to_string(), now));
let add = app.ui.timeline.reveal.contains(&kind.to_string());
if add && app.ui.timeline.reveal.len() == 1 {
app.ui.timeline.reveal.clear();
app.ui.timeline.open_layers.clear();
let tl = &mut app.ui.timeline;
let present = tl.reveal.iter().any(|k| k == kind);
let sel: 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()
};
if add {
if present {
tl.reveal.retain(|k| k != kind);
if tl.reveal.is_empty() {
tl.open_layers.clear();
}
} else {
tl.reveal.retain(|k| k != "props" || !tl.reveal_props.is_empty());
tl.reveal.push(kind.to_string());
tl.open_layers.extend(sel);
}
return;
}
app.ui.timeline.reveal = vec![kind.to_string()];
let sel = if app.session.state.selected_layers.is_empty() {
app.session.active_comp().map(|c| c.layers.iter().map(|l| l.id).collect()).unwrap_or_default()
} else {
app.session.state.selected_layers.clone()
};
app.ui.timeline.open_layers = sel.iter().map(|l| l.0).collect();
if present && tl.reveal.len() == 1 {
tl.reveal.clear();
tl.open_layers.clear();
return;
}
tl.reveal = vec![kind.to_string()];
tl.open_layers = sel.into_iter().collect();
}
fn no_params(p: &Value) -> bool {
@@ -166,9 +195,30 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if k == "animated" {
return run_engine(app, ctx, "anim.reveal", json!({"kind": "keyframes"}));
}
reveal(app, k, now);
reveal(app, k, now, false);
return Ok(Value::Null);
}
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"] => {
app.ui.timeline.reveal.clear();
app.ui.timeline.open_layers.clear();
return Ok(json!({"revealed": 0}));
}
_ => {}
}
}
// 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}`")));
@@ -268,6 +318,23 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
return Ok(Value::Null);
}
"timeline.zoomFit" => app.ui.timeline.pps = None,
"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}));
}
"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" => {
@@ -399,8 +466,16 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
);
Value::Null
}
"app.hide" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
"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" => {
@@ -768,7 +843,7 @@ pub fn menus() -> Vec<&'static str> {
effectcraft_engine::menus::top_level()
}
fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
pub(crate) fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
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()),
@@ -777,12 +852,12 @@ fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
}
/// The entry's shortcut in the active keyboard shortcut preset.
fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option<String> {
pub(crate) fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option<String> {
app.session.shortcuts().shortcut_of(&e.command, &e.params).map(str::to_string)
}
/// Check-mark state of frontend toggles (engine state is answered by the engine).
fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool> {
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() {
@@ -814,7 +889,7 @@ fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool> {
}
}
fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
if e.command == "window.panel" {
return true;
}
+459
View File
@@ -0,0 +1,459 @@
//! The native (macOS) menu bar as a plain, serializable spec. The desktop app turns it into an
//! `NSMenu` with `muda` (`apps/effectcraft/src/native_menu.rs`); everything that decides *what*
//! the menu contains lives here, so it is tested without creating real menus and builds for the
//! web (where the in-window menu bar is used).
//!
//! The spec mirrors the engine's After Effects menu tree ([`effectcraft_engine::menus`]):
//! submenus, separators, labels (Undo/Redo and label names are live), check marks, enabled
//! state, and the active shortcut preset's keys as accelerators. File ▸ Open Recent lists the
//! recent projects. The application menu's Hide / Hide Others / Show All are macOS'
//! predefined items, and a Services submenu is added as on every Mac app.
//!
//! Item ids are positional (`m.3.0.2`), stable while the menu's structure stays the same; the
//! app rebuilds the native menu when [`NativeMenu::structure`] changes and otherwise only pushes
//! label / enabled / checked updates, and only when [`state_key`] changes.
use std::hash::{Hash, Hasher};
use serde::Serialize;
use serde_json::{Value, json};
use crate::EffectcraftApp;
use effectcraft_engine::menus::{MenuNode, menu_bar};
/// What macOS performs itself (no command dispatch).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Role {
Services,
Hide,
HideOthers,
ShowAll,
}
/// One node of the native menu.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum NativeNode {
Item(NativeItem),
Separator,
Submenu {
label: String,
children: Vec<NativeNode>,
},
/// A predefined macOS item; `command` is the engine entry it stands for (empty for Services).
Predefined {
role: Role,
label: String,
command: String,
},
}
/// A clickable item: activating it runs `command` with `params`, exactly like the in-window bar.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct NativeItem {
pub id: String,
pub label: String,
pub command: String,
#[serde(skip_serializing_if = "Value::is_null")]
pub params: Value,
/// The shortcut in EffectCraft notation (`Cmd+Shift+S`).
pub shortcut: Option<String>,
/// The menu key equivalent in `muda` accelerator notation, when the shortcut can be one (see
/// [`accelerator`]).
pub accelerator: Option<String>,
pub enabled: bool,
pub checked: Option<bool>,
}
/// The whole menu bar: top-level menus in order (the application menu first).
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct NativeMenu {
pub menus: Vec<NativeNode>,
}
impl NativeMenu {
/// Every clickable item, depth first.
pub fn items(&self) -> Vec<&NativeItem> {
fn rec<'a>(n: &'a [NativeNode], out: &mut Vec<&'a NativeItem>) {
for x in n {
match x {
NativeNode::Item(i) => out.push(i),
NativeNode::Submenu { children, .. } => rec(children, out),
_ => {}
}
}
}
let mut out = vec![];
rec(&self.menus, &mut out);
out
}
pub fn find(&self, id: &str) -> Option<&NativeItem> {
self.items().into_iter().find(|i| i.id == id)
}
/// Fingerprint of everything that needs a rebuild when it changes (tree shape, commands,
/// accelerators, submenu labels); labels, enabled and check state are updated in place.
pub fn structure(&self) -> u64 {
fn rec(n: &[NativeNode], h: &mut std::collections::hash_map::DefaultHasher) {
for x in n {
match x {
NativeNode::Item(i) => {
0u8.hash(h);
i.id.hash(h);
i.command.hash(h);
i.params.to_string().hash(h);
i.accelerator.hash(h);
}
NativeNode::Separator => 1u8.hash(h),
NativeNode::Submenu { label, children } => {
2u8.hash(h);
label.hash(h);
rec(children, h);
3u8.hash(h);
}
NativeNode::Predefined { role, .. } => {
4u8.hash(h);
role.hash(h);
}
}
}
}
let mut h = std::collections::hash_map::DefaultHasher::new();
rec(&self.menus, &mut h);
h.finish()
}
}
/// Build the native menu spec for the app's current state.
pub fn build(app: &EffectcraftApp) -> NativeMenu {
fn rec(app: &EffectcraftApp, nodes: &[MenuNode], path: &str, out: &mut Vec<NativeNode>) {
let mut services_added = false;
for (i, n) in nodes.iter().enumerate() {
let id = format!("{path}.{i}");
match n {
MenuNode::Separator => out.push(NativeNode::Separator),
MenuNode::Submenu { label, children } => {
let mut kids = vec![];
if label == "Open Recent" {
for (k, p) in app.session.prefs.recent_projects.iter().enumerate() {
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.clone());
kids.push(NativeNode::Item(NativeItem {
id: format!("{id}.r{k}"),
label: name,
command: "file.openRecent".into(),
params: json!({"index": k}),
shortcut: None,
accelerator: None,
enabled: true,
checked: None,
}));
}
if !kids.is_empty() {
kids.push(NativeNode::Separator);
}
}
rec(app, children, &id, &mut kids);
out.push(NativeNode::Submenu { label: label.clone(), children: kids });
}
MenuNode::Item(e) => {
let role = match e.command.as_str() {
"app.hide" => Some(Role::Hide),
"app.hideOthers" => Some(Role::HideOthers),
"app.showAll" => Some(Role::ShowAll),
_ => None,
};
if let Some(role) = role {
// Services sits just above Hide, as in every Mac app.
if role == Role::Hide && !services_added {
out.push(NativeNode::Predefined { role: Role::Services, label: "Services".into(), command: String::new() });
out.push(NativeNode::Separator);
services_added = true;
}
out.push(NativeNode::Predefined { role, label: e.label.clone(), command: e.command.clone() });
continue;
}
let shortcut = crate::menus::entry_shortcut(app, e);
out.push(NativeNode::Item(NativeItem {
id,
label: crate::menus::entry_label(app, e),
command: e.command.clone(),
params: e.params.clone(),
accelerator: shortcut.as_deref().and_then(accelerator),
shortcut,
enabled: crate::menus::entry_enabled(app, e),
checked: crate::menus::entry_checked(app, e),
}));
}
}
}
}
let mut menus = vec![];
rec(app, menu_bar(), "m", &mut menus);
NativeMenu { menus }
}
/// The `muda` accelerator for an EffectCraft shortcut, or `None` when it must stay a plain
/// keyboard shortcut. Only shortcuts with Cmd or Ctrl (and function keys) become menu key
/// equivalents: a single-key shortcut (`V`, `Space`, `U`) or an Alt/Shift-only one would be
/// swallowed by the menu while typing into a text field. Those still work through the
/// window's shortcut dispatcher, as on the other platforms.
pub fn accelerator(shortcut: &str) -> Option<String> {
let parts: Vec<&str> = match shortcut.strip_suffix("++") {
Some(head) => head.split('+').chain(std::iter::once("+")).collect(),
None => shortcut.split('+').collect(),
};
let (key, mods) = parts.split_last()?;
let mut out: Vec<String> = vec![];
let mut primary = false;
for m in mods {
out.push(
match *m {
"Cmd" => {
primary = true;
"Cmd"
}
"Ctrl" => {
primary = true;
"Ctrl"
}
"Alt" => "Alt",
"Shift" => "Shift",
_ => return None,
}
.to_string(),
);
}
let function_key = key.len() >= 2 && key.starts_with('F') && key[1..].parse::<u8>().is_ok_and(|n| (1..=24).contains(&n));
if !primary && !function_key {
return None;
}
let k = match *key {
"Num*" => "NumpadMultiply".to_string(),
"+" => "Equal".to_string(),
k if k.starts_with("Num") && k.len() == 4 => format!("Numpad{}", &k[3..]),
"Escape" | "Space" | "Enter" | "Tab" | "Home" | "End" | "PageUp" | "PageDown" | "Delete" | "Backspace" => key.to_string(),
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => key.to_string(),
k if k.chars().count() == 1 => {
let c = k.chars().next()?;
if c.is_ascii_alphanumeric() || "`\\[],-=./;'".contains(c) {
c.to_ascii_uppercase().to_string()
} else {
return None;
}
}
k if function_key => k.to_string(),
_ => return None,
};
out.push(k);
Some(out.join("+"))
}
/// Cheap per-frame fingerprint of everything menu labels, check marks and enabled state depend
/// on (project revision, selection, history, settings, executed commands, viewer toggles).
pub fn state_key(app: &EffectcraftApp) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let s = &app.session;
s.revision.hash(&mut h);
s.prefs_revision.hash(&mut h);
s.path.hash(&mut h);
s.active_comp_id().map(|c| c.0).hash(&mut h);
for l in &s.state.selected_layers {
l.0.hash(&mut h);
}
s.state.selected_keys.len().hash(&mut h);
s.state.selected_props.len().hash(&mut h);
s.state.project_selection.len().hash(&mut h);
s.state.selected_vertices.len().hash(&mut h);
s.state.clipboard.len().hash(&mut h);
s.state.key_clipboard.len().hash(&mut h);
(s.history.undo.len(), s.history.redo.len()).hash(&mut h);
s.journal.len().hash(&mut h);
s.journal.last().map(|j| &j.0).hash(&mut h);
s.prefs.recent_projects.hash(&mut h);
s.keymaps.active.hash(&mut h);
let v = &app.ui.viewer;
(v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h);
v.res.label().hash(&mut h);
(app.ui.cache_when_idle, app.ui.preview_audio, &app.ui.workspace, app.dialog.is_some()).hash(&mut h);
h.finish()
}
/// Commands that act on a focused text field instead (Edit menu while typing): the egui event to
/// send in their place.
fn text_field_event(command: &str, clipboard: Option<String>) -> Option<Vec<egui::Event>> {
let key = |k: egui::Key, shift: bool| egui::Event::Key {
key: k,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers { shift, ..egui::Modifiers::COMMAND },
};
Some(match command {
"edit.copy" => vec![egui::Event::Copy],
"edit.cut" => vec![egui::Event::Cut],
"edit.paste" => vec![egui::Event::Paste(clipboard?)],
"edit.selectAll" => vec![key(egui::Key::A, false)],
"edit.undo" => vec![key(egui::Key::Z, false)],
"edit.redo" => vec![key(egui::Key::Z, true)],
_ => return None,
})
}
/// Run a native menu item (by id) the way the in-window menu bar does. While a text field has
/// the keyboard, Edit ▸ Cut/Copy/Paste/Select All/Undo/Redo edit the text instead.
pub fn activate(app: &mut EffectcraftApp, ctx: &egui::Context, menu: &NativeMenu, id: &str) -> Result<Value, String> {
let item = menu.find(id).ok_or_else(|| format!("no native menu item `{id}`"))?;
let (command, params) = (item.command.clone(), item.params.clone());
if ctx.egui_wants_keyboard_input() {
let clip = if command == "edit.paste" { app.hooks.clipboard_text.as_ref().and_then(|f| f()) } else { None };
if let Some(events) = text_field_event(&command, clip) {
app.synthetic.extend(events);
ctx.request_repaint();
return Ok(Value::Null);
}
}
let params = if params.is_null() { json!({}) } else { params };
let r = crate::menus::invoke(app, ctx, &command, params);
if let Err(e) = &r {
app.ui.status = e.clone();
}
ctx.request_repaint();
r
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> EffectcraftApp {
let mut s = effectcraft_engine::Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
EffectcraftApp::new(s)
}
#[test]
fn every_engine_entry_is_in_the_native_menu() {
let app = app();
let spec = build(&app);
fn commands(n: &[NativeNode], out: &mut Vec<(String, String)>) {
for x in n {
match x {
NativeNode::Item(i) if i.command != "file.openRecent" => out.push((i.command.clone(), i.params.to_string())),
NativeNode::Predefined { command, .. } if !command.is_empty() => out.push((command.clone(), "null".into())),
NativeNode::Submenu { children, .. } => commands(children, out),
_ => {}
}
}
}
let mut got = vec![];
commands(&spec.menus, &mut got);
let want: Vec<(String, String)> = effectcraft_engine::menus::entries().into_iter().map(|(_, e)| (e.command.clone(), e.params.to_string())).collect();
assert_eq!(got, want, "native menu must mirror the engine tree entry for entry");
// Top-level menus in order.
let tops: Vec<&str> = spec
.menus
.iter()
.filter_map(|n| match n {
NativeNode::Submenu { label, .. } => Some(label.as_str()),
_ => None,
})
.collect();
assert_eq!(tops, effectcraft_engine::menus::top_level());
// Ids are unique.
let ids: std::collections::BTreeSet<&str> = spec.items().iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids.len(), spec.items().len());
// The spec serializes (agents can read it).
let v = serde_json::to_value(&spec).unwrap();
assert!(v["menus"].as_array().is_some_and(|a| a.len() >= 9));
}
#[test]
fn hide_items_are_predefined_on_macos() {
let spec = build(&app());
let mut roles = vec![];
fn rec(n: &[NativeNode], out: &mut Vec<Role>) {
for x in n {
match x {
NativeNode::Predefined { role, .. } => out.push(*role),
NativeNode::Submenu { children, .. } => rec(children, out),
_ => {}
}
}
}
rec(&spec.menus, &mut roles);
if cfg!(target_os = "macos") {
assert_eq!(roles, vec![Role::Services, Role::Hide, Role::HideOthers, Role::ShowAll]);
// Quit and About dispatch our commands (clean shutdown, our About dialog).
assert!(spec.items().iter().any(|i| i.command == "app.quit" && i.accelerator.as_deref() == Some("Cmd+Q")));
assert!(spec.items().iter().any(|i| i.command == "app.about"));
} else {
assert!(roles.is_empty());
}
}
#[test]
fn accelerators_convert() {
assert_eq!(accelerator("Cmd+Shift+S").as_deref(), Some("Cmd+Shift+S"));
assert_eq!(accelerator("Cmd+Alt+;").as_deref(), Some("Cmd+Alt+;"));
assert_eq!(accelerator("Cmd+`").as_deref(), Some("Cmd+`"));
assert_eq!(accelerator("Ctrl+Cmd+F").as_deref(), Some("Ctrl+Cmd+F"));
assert_eq!(accelerator("F3").as_deref(), Some("F3"));
assert_eq!(accelerator("Cmd+Alt+0").as_deref(), Some("Cmd+Alt+0"));
// Keys that would swallow typing stay window shortcuts.
assert_eq!(accelerator("V"), None);
assert_eq!(accelerator("Space"), None);
assert_eq!(accelerator("Shift+="), None);
assert_eq!(accelerator("Alt+["), None);
assert_eq!(accelerator("Num*"), None);
// Every menu shortcut either converts or is a single-key/Alt/Shift shortcut.
let app = app();
for i in build(&app).items() {
if let Some(sc) = &i.shortcut {
let primary = sc.contains("Cmd") || sc.contains("Ctrl");
if primary {
assert!(i.accelerator.is_some(), "{} ({sc}) has no accelerator", i.label);
}
}
}
}
#[test]
fn state_changes_flow_into_the_spec() {
let mut app = app();
let k0 = state_key(&app);
let spec0 = build(&app);
let undo = |s: &NativeMenu| s.items().into_iter().find(|i| i.command == "edit.undo").map(|i| (i.label.clone(), i.enabled)).unwrap();
// Select a layer and toggle Lock: the key changes, the Lock entry gets a check mark.
let first = app.session.active_comp().unwrap().layers[0].id.0;
app.session.execute("layer.select", json!({"layers": [first]})).unwrap();
app.session.execute("layer.setSwitch", json!({"switch": "lock"})).unwrap();
assert_ne!(state_key(&app), k0);
let spec1 = build(&app);
assert_eq!(spec0.structure(), spec1.structure(), "state changes don't rebuild the menu");
let lock = spec1.items().into_iter().find(|i| i.command == "layer.setSwitch" && i.params["switch"] == "lock").unwrap().checked;
assert_eq!(lock, Some(true));
assert_ne!(undo(&spec0), undo(&spec1));
// A recent project changes the structure (Open Recent grows).
app.session.prefs.push_recent("/tmp/Recent Test.ecproj");
let spec2 = build(&app);
assert_ne!(spec1.structure(), spec2.structure());
assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj"));
}
#[test]
fn activation_dispatches_the_menu_command() {
let mut app = app();
let ctx = egui::Context::default();
let spec = build(&app);
let id = spec.items().into_iter().find(|i| i.command == "layer.newNull").unwrap().id.clone();
let n = app.session.active_comp().unwrap().layers.len();
activate(&mut app, &ctx, &spec, &id).unwrap();
assert_eq!(app.session.active_comp().unwrap().layers.len(), n + 1);
assert!(activate(&mut app, &ctx, &spec, "m.999").is_err());
assert!(text_field_event("edit.copy", None).is_some());
assert!(text_field_event("edit.paste", None).is_none());
assert!(text_field_event("layer.newNull", None).is_none());
}
}
File diff suppressed because it is too large. Load diff
+14
View File
@@ -360,6 +360,18 @@ pub struct TimelineState {
/// Properties whose inline expression editor is collapsed.
#[serde(default)]
pub expr_closed: BTreeSet<u64>,
/// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`,
/// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name
/// column is always shown; Modes follows `show_modes` (F4).
#[serde(default = "default_tl_columns")]
pub columns: BTreeSet<String>,
/// The name column shows Source Name instead of Layer Name (click its header).
#[serde(default)]
pub source_name: bool,
}
pub fn default_tl_columns() -> BTreeSet<String> {
["av", "label", "num", "switches", "parent"].map(String::from).into_iter().collect()
}
fn value_graph() -> String {
@@ -391,6 +403,8 @@ impl Default for TimelineState {
graph_reference: false,
graph_transform_box: true,
expr_closed: BTreeSet::new(),
columns: default_tl_columns(),
source_name: false,
}
}
}