Merge branch 'worktree-agent-af1868dabbf03697e'

# Conflicts:
#	docs/parity.md
This commit is contained in:
Brandon Thomas 2026-10-02 22:49:26 -04:00
commit c406b71cba
21 changed files with 2663 additions and 293 deletions

42
Cargo.lock generated
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",
@ -2640,6 +2653,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"
@ -2847,6 +2870,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"

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"] }

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).

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());
}
}

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),

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",

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).

View File

@ -17,7 +17,8 @@
//!
//! An entry's shortcut is the command's default shortcut unless the line gives one (needed for
//! entries with bound parameters). Adobe-service entries (Team Projects, Libraries, Bridge, Media
//! Encoder, Essential Graphics, Behance, Creative Cloud…) are intentionally absent.
//! Encoder, Behance, Creative Cloud…) are intentionally absent; the Essential Graphics workspace
//! uses the Properties panel.
use std::sync::OnceLock;
@ -909,16 +910,22 @@ View
Enter Full Screen | view.fullScreen
Window
Workspace
Default | window.workspace {"name":"Default"} | Shift+F10
Review | window.workspace {"name":"Review"}
Learn | window.workspace {"name":"Learn"}
Small Screen | window.workspace {"name":"Small Screen"} | Shift+F12
Standard | window.workspace {"name":"Standard"} | Shift+F11
---
All Panels | window.workspace {"name":"All Panels"}
Animation | window.workspace {"name":"Animation"}
Default | window.workspace {"name":"Default"} | Shift+F10
Color | window.workspace {"name":"Color"}
Effects | window.workspace {"name":"Effects"}
Essential Graphics | window.workspace {"name":"Essential Graphics"}
Minimal | window.workspace {"name":"Minimal"}
Motion Tracking | window.workspace {"name":"Motion Tracking"}
Paint | window.workspace {"name":"Paint"}
Small Screen | window.workspace {"name":"Small Screen"} | Shift+F12
Standard | window.workspace {"name":"Standard"} | Shift+F11
Text | window.workspace {"name":"Text"}
Undocked Panels | window.workspace {"name":"Undocked Panels"}
---
Reset to Saved Layout | window.resetWorkspace
Save Changes to this Workspace | window.saveWorkspace

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 {

View File

@ -175,7 +175,43 @@ fn vsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode {
DockNode::Split { vertical: true, size, a: Box::new(a), b: Box::new(b) }
}
pub const WORKSPACES: [&str; 10] = ["Default", "Standard", "Small Screen", "Animation", "Effects", "Motion Tracking", "Paint", "Text", "Minimal", "All Panels"];
/// The built-in workspaces: After Effects 2026's (Window ▸ Workspace), less Libraries (an Adobe
/// service). Learn shows the Home screen (community links instead of Adobe's tutorials);
/// Essential Graphics uses the Properties panel; Undocked Panels floats the panels over the
/// Composition panel.
pub const WORKSPACES: &[&str] = &[
"Default",
"Standard",
"Small Screen",
"Animation",
"Effects",
"Motion Tracking",
"Paint",
"Text",
"Minimal",
"All Panels",
"Review",
"Learn",
"Essential Graphics",
"Color",
"Undocked Panels",
];
/// The floating panels a workspace starts with (Undocked Panels), in points for a window of
/// about 1680×1020 (they are kept inside the window when drawn).
pub fn workspace_floating(name: &str) -> Vec<Floating> {
use PanelKind::*;
let f = |panels: &[PanelKind], rect: [f32; 4]| Floating { panels: panels.to_vec(), active: 0, rect };
match name {
"Undocked Panels" => vec![
f(&[Project, EffectControls], [24.0, 110.0, 320.0, 420.0]),
f(&[Preview, Info, Audio], [1330.0, 110.0, 320.0, 300.0]),
f(&[EffectsPresets, Properties, Character, Paragraph], [1330.0, 430.0, 320.0, 360.0]),
f(&[Timeline, RenderQueue], [24.0, 640.0, 1280.0, 340.0]),
],
_ => vec![],
}
}
/// The default layout of a named workspace.
pub fn workspace(name: &str) -> DockNode {
@ -229,6 +265,40 @@ pub fn workspace(name: &str) -> DockNode {
tabs(&[Timeline], 0),
),
"Minimal" => vsplit(Ratio(0.6), tabs(&[Composition], 0), tabs(&[Timeline], 0)),
// Review: a large viewer with the markers and the project beside it.
"Review" => vsplit(
Ratio(0.66),
hsplit(FixedA(280.0), tabs(&[Project, Markers], 0), hsplit(FixedB(260.0), tabs(&[Composition, Layer], 0), right(&[Preview, Info], &[Audio]))),
tabs(&[Timeline, RenderQueue], 0),
),
// Essential Graphics: the Properties panel (After Effects' successor to the Essential
// Graphics panel) full height on the right, type panels beside the viewer.
"Essential Graphics" => hsplit(
FixedB(320.0),
vsplit(
Ratio(0.58),
hsplit(
FixedA(280.0),
tabs(&[Project, EffectControls], 0),
hsplit(FixedB(260.0), tabs(&[Composition, Layer], 0), right(&[Character, Paragraph], &[Align, Preview])),
),
tabs(&[Timeline], 0),
),
stack(&[(Properties, true, None), (EffectsPresets, false, None)]),
),
// Color: wide Effect Controls for grading effects, Info for colour readouts.
"Color" => vsplit(
Ratio(0.6),
hsplit(
FixedA(360.0),
tabs(&[EffectControls, Project], 0),
hsplit(FixedB(300.0), tabs(&[Composition, Layer], 0), right(&[Info, Preview], &[EffectsPresets])),
),
tabs(&[Timeline], 0),
),
// Undocked Panels: the Composition panel docked, the others floating
// ([`workspace_floating`]).
"Undocked Panels" => tabs(&[Composition, Layer, Flowchart], 0),
"Small Screen" => vsplit(
Ratio(0.55),
hsplit(
@ -817,8 +887,59 @@ mod tests {
fn workspaces_contain_core_panels() {
for w in WORKSPACES {
let d = workspace(w);
assert!(d.contains(PanelKind::Timeline), "{w}");
let floating: Vec<PanelKind> = workspace_floating(w).into_iter().flat_map(|f| f.panels).collect();
assert!(d.contains(PanelKind::Timeline) || floating.contains(&PanelKind::Timeline), "{w}");
assert!(d.contains(PanelKind::Composition) || d.contains(PanelKind::Layer), "{w}");
// A panel is in one place only.
let mut all = vec![];
d.panels(&mut all);
all.extend(floating);
let n = all.len();
all.sort_by_key(|p| p.id());
all.dedup();
assert_eq!(all.len(), n, "{w} has a panel twice");
}
}
#[test]
fn after_effects_workspaces_exist() {
// Window ▸ Workspace in After Effects 2026, less Libraries (an Adobe service).
for w in [
"Default",
"Review",
"Learn",
"Small Screen",
"Standard",
"All Panels",
"Animation",
"Essential Graphics",
"Color",
"Effects",
"Minimal",
"Motion Tracking",
"Paint",
"Text",
"Undocked Panels",
] {
assert!(WORKSPACES.contains(&w), "{w}");
}
assert!(!WORKSPACES.contains(&"Libraries"));
// Layouts differ from Default (except Learn, which is Default plus the Home screen).
let def = workspace("Default");
for w in WORKSPACES.iter().filter(|w| !matches!(**w, "Default" | "Learn")) {
assert_ne!(workspace(w), def, "{w} falls back to Default");
}
assert!(workspace("Essential Graphics").contains(PanelKind::Properties));
assert!(workspace("Color").contains(PanelKind::EffectControls));
assert!(!workspace_floating("Undocked Panels").is_empty());
// Every built-in workspace is in Window ▸ Workspace.
let menu: Vec<String> = effectcraft_engine::menus::entries()
.into_iter()
.filter(|(_, e)| e.command == "window.workspace")
.filter_map(|(_, e)| e.params.get("name").and_then(|v| v.as_str()).map(str::to_string))
.collect();
for w in WORKSPACES {
assert!(menu.iter().any(|m| m == w), "{w} missing from Window > Workspace");
}
}

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)]
@ -158,6 +164,10 @@ pub struct EffectcraftApp {
/// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one
/// under the pointer.
pub(crate) dock_rects: Vec<(PanelKind, egui::Rect)>,
/// Home screen: recent-project thumbnail textures by path (None = no thumbnail).
pub(crate) home_thumbs: std::collections::HashMap<String, Option<egui::TextureHandle>>,
/// The (project path, saved revision) whose thumbnail was stored last.
pub(crate) home_thumb_saved: Option<(String, u64)>,
}
impl EffectcraftApp {
@ -202,6 +212,8 @@ impl EffectcraftApp {
applied_prefs: None,
recovery: None,
dock_rects: vec![],
home_thumbs: Default::default(),
home_thumb_saved: None,
}
.with_ui_commands()
}
@ -267,8 +279,12 @@ impl EffectcraftApp {
pub fn set_workspace(&mut self, name: &str) {
self.ui.workspace = name.to_string();
self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name));
self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_default();
self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(name));
self.ui.maximized = None;
// Learn: the Home screen (community links) in the Composition panel.
if name == "Learn" {
self.ui.start_screen = true;
}
}
/// Built-in workspaces followed by the saved ones (Window ▸ Workspace ▸ Save as New Workspace).
@ -757,6 +773,7 @@ impl EffectcraftApp {
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
self.dock_area(ui, body);
panels::precomp::mini_flowchart(self, &ctx);
panels::home::capture_thumbnail(self);
panels::dialogs::show(self, &ctx);
self.draw_toast(ui, full);
}

View File

@ -59,6 +59,21 @@ 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!("flowchart.options", "Flowchart Options", [], None),
uic!("flowchart.graph", "Flowchart Graph", [], None),
uic!("timeline.collapseAll", "Collapse All", [], Some("Cmd+`")),
uic!("tool.selection", "Selection Tool", [], Some("V")),
uic!("tool.hand", "Hand Tool", [], Some("H")),
@ -89,26 +104,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 {
@ -167,9 +198,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}`")));
@ -269,6 +321,24 @@ 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}));
}
"flowchart.options" | "flowchart.graph" => return crate::panels::flowchart::command(app, id, &params),
"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" => {
@ -400,8 +470,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" => {
@ -638,6 +716,13 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
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
}
@ -769,7 +854,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()),
@ -778,12 +863,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() {
@ -815,7 +900,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;
}

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());
}
}

View File

@ -0,0 +1,460 @@
//! The Flowchart panel (Composition ▸ Composition Flowchart): a node graph of a composition and
//! everything it uses — nested comps, footage, solids — optionally with its layers and their
//! effects. Left to right or top to bottom; double-click a comp to open it; Cmd/Ctrl-click a
//! node to reveal it in the Project panel (layers: select the layer); drag to pan.
//!
//! [`build`] is pure and cycle-safe (a comp nested in itself is drawn once, with an edge back),
//! so agents get the same graph through `flowchart.graph`.
use std::collections::{BTreeMap, BTreeSet};
use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Project};
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// Flowchart panel options (the panel's switches).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct FlowOptions {
/// Show layers between a comp and its sources.
pub layers: bool,
/// Show each layer's effects (with layers shown).
pub effects: bool,
/// Show solids.
pub solids: bool,
/// Top to bottom instead of left to right.
pub vertical: bool,
/// The comp the chart starts from (None = the active comp).
pub root: Option<u64>,
#[serde(skip)]
pub pan: [f32; 2],
/// Selected node id.
#[serde(skip)]
pub selected: Option<String>,
}
impl Default for FlowOptions {
fn default() -> Self {
FlowOptions { layers: false, effects: false, solids: true, vertical: false, root: None, pan: [0.0, 0.0], selected: None }
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct FlowNode {
/// `item:<id>`, `layer:<comp>:<layer>` or `fx:<comp>:<layer>:<uid>`.
pub id: String,
/// `comp`, `footage`, `solid`, `layer` or `effect`.
pub kind: &'static str,
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub item: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comp: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<u64>,
/// Distance from the root (column, or row when vertical).
pub depth: usize,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct FlowGraph {
pub nodes: Vec<FlowNode>,
/// (from, to) node ids: from a comp to what it uses.
pub edges: Vec<(String, String)>,
}
/// Build the flowchart of `root` (a comp). Shared items appear once; cycles stop at the comp
/// already on the path.
pub fn build(project: &Project, root: ItemId, opts: &FlowOptions) -> FlowGraph {
struct B<'a> {
p: &'a Project,
o: &'a FlowOptions,
nodes: Vec<FlowNode>,
index: BTreeMap<String, usize>,
edges: Vec<(String, String)>,
stack: BTreeSet<u64>,
}
impl B<'_> {
fn node(&mut self, n: FlowNode) -> String {
let id = n.id.clone();
match self.index.get(&id) {
Some(&i) => self.nodes[i].depth = self.nodes[i].depth.min(n.depth),
None => {
self.index.insert(id.clone(), self.nodes.len());
self.nodes.push(n);
}
}
id
}
fn edge(&mut self, a: &str, b: &str) {
let e = (a.to_string(), b.to_string());
if !self.edges.contains(&e) {
self.edges.push(e);
}
}
fn item(&mut self, id: ItemId, depth: usize) -> Option<String> {
let it = self.p.item(id)?;
let kind = match &it.kind {
ItemKind::Comp(_) => "comp",
ItemKind::Footage(_) => "footage",
ItemKind::Solid(_) => "solid",
ItemKind::Folder => return None,
};
if kind == "solid" && !self.o.solids {
return None;
}
let seen = self.index.contains_key(&format!("item:{}", id.0));
let nid = self.node(FlowNode { id: format!("item:{}", id.0), kind, label: it.name.clone(), item: Some(id.0), comp: None, layer: None, depth });
if kind == "comp" && !seen && !self.stack.contains(&id.0) {
self.comp(id, depth);
}
Some(nid)
}
fn comp(&mut self, cid: ItemId, depth: usize) {
let Some(c) = self.p.comp(cid) else { return };
self.stack.insert(cid.0);
let from = format!("item:{}", cid.0);
for l in &c.layers {
let src_depth = if self.o.layers { depth + 2 } else { depth + 1 };
let mut parent = from.clone();
if self.o.layers {
let lid = self.node(FlowNode {
id: format!("layer:{}:{}", cid.0, l.id.0),
kind: "layer",
label: l.name.clone(),
item: None,
comp: Some(cid.0),
layer: Some(l.id.0),
depth: depth + 1,
});
self.edge(&from, &lid);
if self.o.effects
&& let Some(fx) = l.effects()
{
for g in fx.groups() {
let fid = self.node(FlowNode {
id: format!("fx:{}:{}:{}", cid.0, l.id.0, g.uid),
kind: "effect",
label: g.name.clone(),
item: None,
comp: Some(cid.0),
layer: Some(l.id.0),
depth: depth + 2,
});
self.edge(&lid, &fid);
}
}
parent = lid;
}
let src = match l.source {
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } | LayerSource::Model { item } => Some(item),
_ => None,
};
if let Some(item) = src {
// A comp already on the path: link back to it, don't descend (cycle).
if self.stack.contains(&item.0) {
self.edge(&parent, &format!("item:{}", item.0));
} else if let Some(sid) = self.item(item, src_depth) {
self.edge(&parent, &sid);
}
}
}
self.stack.remove(&cid.0);
}
}
let mut b = B { p: project, o: opts, nodes: vec![], index: BTreeMap::new(), edges: vec![], stack: BTreeSet::new() };
b.item(root, 0);
FlowGraph { nodes: b.nodes, edges: b.edges }
}
const NODE_W: f32 = 168.0;
const NODE_H: f32 = 34.0;
/// Node positions (top-left, before panning): one column (or row) per depth, in discovery order.
pub fn layout(g: &FlowGraph, vertical: bool) -> Vec<[f32; 2]> {
let mut slot: BTreeMap<usize, usize> = BTreeMap::new();
g.nodes
.iter()
.map(|n| {
let k = slot.entry(n.depth).or_insert(0);
let i = *k as f32;
*k += 1;
let d = n.depth as f32;
if vertical { [i * (NODE_W + 24.0), d * (NODE_H + 46.0)] } else { [d * (NODE_W + 60.0), i * (NODE_H + 14.0)] }
})
.collect()
}
/// The comp the panel charts.
pub fn root(app: &EffectcraftApp) -> Option<ItemId> {
app.ui.flowchart.root.map(ItemId).filter(|r| app.session.project.comp(*r).is_some()).or_else(|| app.session.active_comp_id())
}
/// `flowchart.options` / `flowchart.graph` (UI commands).
pub fn command(app: &mut EffectcraftApp, id: &str, p: &Value) -> Result<Value, String> {
let o = &mut app.ui.flowchart;
let b = |k: &str| p.get(k).and_then(Value::as_bool);
if let Some(v) = b("layers") {
o.layers = v;
}
if let Some(v) = b("effects") {
o.effects = v;
}
if let Some(v) = b("solids") {
o.solids = v;
}
if let Some(d) = p.get("direction").and_then(Value::as_str) {
o.vertical = match d {
"lr" | "leftToRight" => false,
"tb" | "topToBottom" => true,
_ => return Err(format!("direction: lr|tb, not `{d}`")),
};
}
if let Some(c) = p.get("comp") {
let cid = match c {
Value::Number(n) => n.as_u64().map(ItemId),
Value::String(s) => app.session.project.find_by_name(s).map(|i| i.id),
_ => None,
};
let cid = cid.filter(|c| app.session.project.comp(*c).is_some()).ok_or("comp: a composition id or name")?;
app.ui.flowchart.root = Some(cid.0);
}
let o = app.ui.flowchart.clone();
if id == "flowchart.graph" {
let r = root(app).ok_or("no composition")?;
return Ok(serde_json::to_value(build(&app.session.project, r, &o)).unwrap_or_default());
}
Ok(json!({"layers": o.layers, "effects": o.effects, "solids": o.solids, "direction": if o.vertical { "tb" } else { "lr" }, "root": o.root}))
}
fn kind_style(kind: &str) -> (Icon, Color32) {
match kind {
"comp" => (Icon::Comp, Color32::from_rgb(0x3d, 0x5a, 0x8c)),
"footage" => (Icon::Footage, Color32::from_rgb(0x3c, 0x6e, 0x58)),
"solid" => (Icon::Solid, Color32::from_rgb(0x6a, 0x58, 0x3a)),
"effect" => (Icon::Fx, Color32::from_rgb(0x6c, 0x44, 0x6e)),
_ => (Icon::Null, Color32::from_rgb(0x44, 0x48, 0x52)),
}
}
/// Draw the Flowchart panel.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.tl_bg);
let Some(rid) = root(app) else {
p.text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
return;
};
// ---- toolbar: switches and direction.
let bar = Rect::from_min_size(rect.min, vec2(rect.width(), 30.0));
p.rect_filled(bar, 0.0, t.panel_bg);
let root_name = app.session.project.item(rid).map(|i| i.name.clone()).unwrap_or_default();
p.text(pos2(bar.min.x + 10.0, bar.center().y), Align2::LEFT_CENTER, format!("Flowchart: {root_name}"), Tokens::medium(12.0), t.text);
let mut x = bar.max.x - 8.0;
let mut cmds: Vec<(String, Value)> = vec![];
let o = app.ui.flowchart.clone();
for (key, label, on) in [
("direction", if o.vertical { "Top to Bottom" } else { "Left to Right" }, false),
("effects", "Effects", o.effects),
("layers", "Layers", o.layers),
("solids", "Solids", o.solids),
] {
let w = 18.0 + label.len() as f32 * 6.6;
let r = Rect::from_min_size(pos2(x - w, bar.min.y + 5.0), vec2(w, 20.0));
x -= w + 6.0;
if widgets::text_button(ui, r, label, on, &t, egui::Id::new(("flow-opt", key))).clicked() {
let v = if key == "direction" { json!({"direction": if o.vertical { "lr" } else { "tb" }}) } else { json!({key: !on}) };
cmds.push(("flowchart.options".into(), v));
}
app.auto.add(&format!("flowchart.option.{key}"), r, label);
}
// ---- graph.
let body = Rect::from_min_max(pos2(rect.min.x, bar.max.y), rect.max);
let g = build(&app.session.project, rid, &o);
let pos = layout(&g, o.vertical);
let origin = body.min + vec2(24.0 + o.pan[0], 24.0 + o.pan[1]);
let bg = ui.interact(body, egui::Id::new("flow-bg"), Sense::click_and_drag());
if bg.dragged() {
let d = bg.drag_delta();
app.ui.flowchart.pan[0] += d.x;
app.ui.flowchart.pan[1] += d.y;
}
if ui.rect_contains_pointer(body) {
let s = ui.input(|i| i.smooth_scroll_delta);
if s != egui::Vec2::ZERO {
app.ui.flowchart.pan[0] += s.x;
app.ui.flowchart.pan[1] += s.y;
}
}
if bg.clicked() {
app.ui.flowchart.selected = None;
}
let gp = p.with_clip_rect(body);
let rect_of = |i: usize| Rect::from_min_size(origin + vec2(pos[i][0], pos[i][1]), vec2(NODE_W, NODE_H));
let idx: BTreeMap<&str, usize> = g.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i)).collect();
// Edges first (curves with an arrowhead at the target).
for (a, b) in &g.edges {
let (Some(&ia), Some(&ib)) = (idx.get(a.as_str()), idx.get(b.as_str())) else { continue };
let (ra, rb) = (rect_of(ia), rect_of(ib));
let back = g.nodes[ib].depth <= g.nodes[ia].depth;
let (s, e, n) = if o.vertical { (ra.center_bottom(), rb.center_top(), vec2(0.0, 1.0)) } else { (ra.right_center(), rb.left_center(), vec2(1.0, 0.0)) };
let k = if o.vertical { (e.y - s.y).abs() * 0.5 } else { (e.x - s.x).abs() * 0.5 }.max(30.0);
let col = if back { Color32::from_rgb(0xd0, 0x80, 0x40) } else { t.text_faint };
let curve = egui::epaint::CubicBezierShape::from_points_stroke([s, s + n * k, e - n * k, e], false, Color32::TRANSPARENT, Stroke::new(1.2, col));
gp.add(curve);
let (l, r) = if o.vertical { (vec2(-4.0, -7.0), vec2(4.0, -7.0)) } else { (vec2(-7.0, -4.0), vec2(-7.0, 4.0)) };
gp.add(egui::Shape::convex_polygon(vec![e, e + l, e + r], col, Stroke::NONE));
}
let cmd_down = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
for (i, n) in g.nodes.iter().enumerate() {
let r = rect_of(i);
if !r.intersects(body) {
continue;
}
let (icon, fill) = kind_style(n.kind);
let selected = o.selected.as_deref() == Some(n.id.as_str());
let resp = ui.interact(r.intersect(body), egui::Id::new(("flow-node", &n.id)), Sense::click());
let fill = if resp.hovered() { fill.gamma_multiply(1.25) } else { fill };
gp.rect_filled(r, 5.0, fill);
gp.rect_stroke(
r,
5.0,
Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { Color32::from_black_alpha(120) }),
StrokeKind::Inside,
);
icons::paint(&gp, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), icon, Color32::WHITE);
gp.with_clip_rect(r.shrink(2.0).intersect(body)).text(
pos2(r.min.x + 30.0, r.center().y),
Align2::LEFT_CENTER,
&n.label,
Tokens::ui(12.0),
Color32::WHITE,
);
app.auto.add(&format!("flowchart.node.{}", n.id), r, &n.label);
if resp.clicked() {
app.ui.flowchart.selected = Some(n.id.clone());
if cmd_down {
// Reveal: items in the Project panel, layers selected in their comp.
match (n.item, n.comp, n.layer) {
(Some(item), ..) => {
cmds.push(("project.select".into(), json!({"items": [item]})));
cmds.push(("window.panel".into(), json!({"panel": "project"})));
}
(None, Some(c), Some(l)) => {
cmds.push(("comp.open".into(), json!({"comp": c})));
cmds.push(("layer.select".into(), json!({"layers": [l]})));
}
_ => {}
}
}
}
if resp.double_clicked() {
match (n.kind, n.item, n.comp) {
("comp", Some(c), _) => cmds.push(("comp.open".into(), json!({"comp": c}))),
(_, _, Some(c)) => cmds.push(("comp.open".into(), json!({"comp": c}))),
_ => {}
}
}
resp.on_hover_text(format!("{} — double-click to open, {}-click to reveal", n.label, if cfg!(target_os = "macos") { "Cmd" } else { "Ctrl" }));
}
for (id, params) in cmds {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
app.ui.status = e;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::Session;
/// Main ⊃ Mid ⊃ Inner (with a solid), Main also uses Inner directly.
fn session() -> (Session, ItemId, ItemId, ItemId) {
let mut s = Session::default();
let id = |v: Value| ItemId(v["id"].as_u64().or_else(|| v["comp"].as_u64()).or_else(|| v["item"].as_u64()).unwrap());
let inner = id(s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "duration": 2})).unwrap());
s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 100, "height": 100})).unwrap();
s.execute("layer.select", json!({"layers": []})).unwrap();
let mid = id(s.execute("comp.new", json!({"name": "Mid", "width": 100, "height": 100, "duration": 2})).unwrap());
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
let main = id(s.execute("comp.new", json!({"name": "Main", "width": 100, "height": 100, "duration": 2})).unwrap());
s.execute("layer.addItem", json!({"item": mid.0})).unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
s.execute("layer.newText", json!({"text": "Title"})).unwrap();
(s, main, mid, inner)
}
#[test]
fn nested_comps_make_a_graph() {
let (s, main, mid, inner) = session();
let g = build(&s.project, main, &FlowOptions::default());
let label = |id: &str| g.nodes.iter().find(|n| n.id == id).map(|n| (n.label.clone(), n.depth));
assert_eq!(label(&format!("item:{}", main.0)), Some(("Main".into(), 0)));
assert_eq!(label(&format!("item:{}", mid.0)), Some(("Mid".into(), 1)));
// Inner is reached at depth 1 (directly) and 2 (through Mid): drawn once, at depth 1.
assert_eq!(label(&format!("item:{}", inner.0)), Some(("Inner".into(), 1)));
assert_eq!(g.nodes.iter().filter(|n| n.label == "Inner").count(), 1);
let has = |a: ItemId, b: ItemId| g.edges.contains(&(format!("item:{}", a.0), format!("item:{}", b.0)));
assert!(has(main, mid) && has(mid, inner) && has(main, inner));
// The solid is shown (Solids on) and hidden with Solids off; text layers have no source.
assert!(g.nodes.iter().any(|n| n.kind == "solid"));
let g2 = build(&s.project, main, &FlowOptions { solids: false, ..Default::default() });
assert!(!g2.nodes.iter().any(|n| n.kind == "solid"));
// Layers between comps and sources; effects under layers.
let g3 = build(&s.project, main, &FlowOptions { layers: true, ..Default::default() });
assert!(g3.nodes.iter().any(|n| n.kind == "layer" && n.label == "Title"));
let mid_layer = g3.nodes.iter().find(|n| n.kind == "layer" && n.comp == Some(main.0) && n.label == "Mid").unwrap();
assert!(g3.edges.contains(&(mid_layer.id.clone(), format!("item:{}", mid.0))));
// Layout: one column per depth, no overlaps.
let pos = layout(&g3, false);
for i in 0..pos.len() {
for j in i + 1..pos.len() {
assert!(pos[i] != pos[j], "{} and {} overlap", g3.nodes[i].label, g3.nodes[j].label);
}
}
}
#[test]
fn cycles_are_safe() {
let (mut s, main, mid, _) = session();
// Make Mid contain Main: Main → Mid → Main.
s.execute("comp.open", json!({"comp": mid.0})).unwrap();
// Adding a cyclic layer may be refused by the engine; build a cycle in the model directly.
let mut p = (*s.project).clone();
let mut c = (*p.comp(mid).unwrap()).clone();
let mut l = c.layers[0].clone();
l.id = effectcraft_engine::project::LayerId(9999);
l.source = LayerSource::Comp { item: main };
c.layers.push(l);
if let Some(ItemKind::Comp(arc)) = p.items.get_mut(&mid).map(|i| &mut i.kind) {
*arc = std::sync::Arc::new(c);
}
let g = build(&p, main, &FlowOptions { layers: true, effects: true, ..Default::default() });
assert_eq!(g.nodes.iter().filter(|n| n.kind == "comp" && n.label == "Main").count(), 1);
// The back edge points at the root.
assert!(g.edges.iter().any(|(_, b)| *b == format!("item:{}", main.0)));
assert!(g.nodes.len() < 50);
}
#[test]
fn options_command() {
let (s, main, ..) = session();
let mut app = EffectcraftApp::new(s);
let r = command(&mut app, "flowchart.options", &json!({"layers": true, "direction": "tb"})).unwrap();
assert_eq!(r["direction"], "tb");
assert!(app.ui.flowchart.layers && app.ui.flowchart.vertical);
assert!(command(&mut app, "flowchart.options", &json!({"direction": "diagonal"})).is_err());
let g = command(&mut app, "flowchart.graph", &json!({"comp": "Main"})).unwrap();
assert_eq!(g["nodes"][0]["id"], format!("item:{}", main.0));
assert!(g["edges"].as_array().is_some_and(|e| !e.is_empty()));
}
}

View File

@ -0,0 +1,389 @@
//! The Home screen (shown in the Composition panel at launch, from the header's Home button, and
//! by the Learn workspace): New Project / Open Project / New Composition / Open Demo, the recent
//! projects (File ▸ Open Recent, from Settings) with thumbnails, folders and dates, and where
//! After Effects has its Learn / What's New area, the ArtCraft community links.
//!
//! Thumbnails: when a project is opened or saved and the viewer has its frame, a 96×54 RGB
//! thumbnail is stored in the config store (`thumb-<hash>.txt`, hex) and shown here.
use std::hash::{Hash, Hasher};
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde::Serialize;
use serde_json::json;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
pub const THUMB_W: usize = 96;
pub const THUMB_H: usize = 54;
/// One row of the recent projects list.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct RecentEntry {
pub index: usize,
pub path: String,
pub name: String,
pub folder: String,
/// Last modified (`2026-10-02 14:05`, UTC), when the file is readable.
pub modified: Option<String>,
pub exists: bool,
}
/// The recent projects, most recent first (Settings ▸ General ▸ recent items).
pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec<RecentEntry> {
prefs
.recent_projects
.iter()
.enumerate()
.map(|(index, p)| {
let path = std::path::Path::new(p);
let meta = std::fs::metadata(path).ok();
let modified = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| format_utc(d.as_secs() as i64));
RecentEntry {
index,
path: p.clone(),
name: path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()),
folder: path.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
modified,
exists: meta.is_some(),
}
})
.collect()
}
/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds.
pub fn format_utc(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem % 3600) / 60)
}
/// The config-store name of a project's thumbnail.
pub fn thumb_name(path: &str) -> String {
let mut h = std::collections::hash_map::DefaultHasher::new();
path.hash(&mut h);
format!("thumb-{:016x}.txt", h.finish())
}
/// Downscale an image to a letterboxed THUMB_W×THUMB_H RGB thumbnail (hex text).
pub fn encode_thumb(img: &egui::ColorImage) -> String {
let [w, h] = img.size;
let mut out = String::with_capacity(THUMB_W * THUMB_H * 6);
let k = (THUMB_W as f32 / w.max(1) as f32).min(THUMB_H as f32 / h.max(1) as f32);
let (dw, dh) = (w as f32 * k, h as f32 * k);
let (ox, oy) = ((THUMB_W as f32 - dw) / 2.0, (THUMB_H as f32 - dh) / 2.0);
for y in 0..THUMB_H {
for x in 0..THUMB_W {
let (fx, fy) = ((x as f32 + 0.5 - ox) / k, (y as f32 + 0.5 - oy) / k);
let c = if fx >= 0.0 && fy >= 0.0 && (fx as usize) < w && (fy as usize) < h {
let p = img.pixels[fy as usize * w + fx as usize];
[p.r(), p.g(), p.b()]
} else {
[0x16, 0x17, 0x1c]
};
for v in c {
out.push_str(&format!("{v:02x}"));
}
}
}
out
}
pub fn decode_thumb(text: &str) -> Option<egui::ColorImage> {
let t = text.trim();
if t.len() != THUMB_W * THUMB_H * 6 {
return None;
}
let b: Vec<u8> = (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16)).collect::<Result<_, _>>().ok()?;
let px = b.chunks_exact(3).map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect();
Some(egui::ColorImage::new([THUMB_W, THUMB_H], px))
}
/// Store the viewer's frame as the open project's thumbnail after it is opened or saved (once per
/// saved revision, when the viewer shows that revision).
pub fn capture_thumbnail(app: &mut EffectcraftApp) {
let s = &app.session;
let (Some(path), Some(_)) = (s.path.clone(), s.config.as_ref()) else { return };
if s.is_dirty() {
return;
}
let key = (path.clone(), s.saved_revision);
if app.home_thumb_saved.as_ref() == Some(&key) {
return;
}
if app.viewer_shown.as_ref().map(|(_, k)| k.revision) != Some(s.revision) {
return;
}
let Some(img) = app.viewer_pixels() else { return };
let text = encode_thumb(&img);
if let Some(c) = &app.session.config
&& let Err(e) = c.write(&thumb_name(&path), &text)
{
log::warn!("project thumbnail: {e}");
}
app.home_thumbs.remove(&path);
app.home_thumb_saved = Some(key);
}
fn thumb_texture(app: &mut EffectcraftApp, ctx: &egui::Context, path: &str) -> Option<egui::TextureHandle> {
if let Some(t) = app.home_thumbs.get(path) {
return t.clone();
}
let tex = app
.session
.config
.as_ref()
.and_then(|c| c.read(&thumb_name(path)))
.and_then(|t| decode_thumb(&t))
.map(|img| ctx.load_texture(format!("home-thumb-{path}"), img, egui::TextureOptions::LINEAR));
app.home_thumbs.insert(path.to_string(), tex.clone());
tex
}
/// Draw the Home screen.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, Color32::from_rgb(0x1b, 0x1d, 0x24));
let wide = rect.width() >= 820.0;
let pad = 32.0;
let left_w = if wide { 250.0 } else { rect.width() - pad * 2.0 };
let mut actions: Vec<(&str, serde_json::Value)> = vec![];
// ---- left: brand + start actions.
let x0 = rect.min.x + pad;
let mut y = rect.min.y + pad;
crate::header::paint_logo(&p, Rect::from_min_size(pos2(x0, y), vec2(40.0, 40.0)));
p.text(pos2(x0 + 52.0, y + 12.0), Align2::LEFT_CENTER, "EffectCraft", Tokens::semibold(22.0), Color32::WHITE);
p.text(pos2(x0 + 52.0, y + 32.0), Align2::LEFT_CENTER, format!("Version {}", env!("CARGO_PKG_VERSION")), Tokens::ui(11.0), t.text_faint);
y += 64.0;
for (label, id, primary) in [
("New Project", "file.newProject", true),
("Open Project…", "file.open", false),
("New Composition", "app.newComp", false),
("Open Demo Project", "file.openDemoProject", false),
("Import Footage…", "file.import", false),
] {
let r = Rect::from_min_size(pos2(x0, y), vec2(left_w.min(250.0), 32.0));
if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() {
actions.push((id, json!({})));
}
app.auto.add(&format!("home.{id}"), r, label);
y += 40.0;
}
// ---- community (After Effects' Learn / What's New area).
let (cx, mut cy, cw) = if wide { (x0, y + 18.0, left_w) } else { (x0, y + 10.0, left_w) };
p.text(pos2(cx, cy), Align2::LEFT_CENTER, "Community", Tokens::semibold(13.0), t.text);
cy += 16.0;
p.text(pos2(cx, cy), Align2::LEFT_CENTER, "Tutorials, help and what's new", Tokens::ui(11.0), t.text_faint);
cy += 16.0;
let links = [
(Icon::Chat, "Join the ArtCraft Discord", "help.discord"),
(Icon::Globe, "getartcraft.com", "help.website"),
(Icon::Globe, "EffectCraft home page", "help.appPage"),
(Icon::Code, "EffectCraft on GitHub", "help.github"),
];
for (icon, label, cmd) in links {
let r = Rect::from_min_size(pos2(cx, cy), vec2(cw.min(250.0), 30.0));
let resp = ui.interact(r, egui::Id::new(("home-link", cmd)), Sense::click());
let discord = cmd == "help.discord";
let bg = if discord {
Color32::from_rgb(0x58, 0x65, 0xf2)
} else if resp.hovered() {
t.hover
} else {
Color32::from_rgb(0x2a, 0x2c, 0x34)
};
p.rect_filled(r, 15.0, bg);
icons::paint(&p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(14.0, 14.0)), icon, Color32::WHITE);
p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), Color32::WHITE);
app.auto.add(&format!("home.{cmd}"), r, label);
if resp.clicked() {
let _ = app.session.execute(cmd, json!({}));
}
cy += 36.0;
}
cy += 8.0;
p.text(pos2(cx, cy), Align2::LEFT_CENTER, "More ArtCraft apps", Tokens::ui(11.0), t.text_faint);
cy += 12.0;
let sib = effectcraft_engine::links::SIBLINGS;
let per_row = 2usize;
let sw = (cw.min(250.0) - 6.0) / per_row as f32;
for (i, (name, slug)) in sib.iter().enumerate() {
let r = Rect::from_min_size(pos2(cx + (i % per_row) as f32 * (sw + 6.0), cy + (i / per_row) as f32 * 28.0), vec2(sw, 24.0));
let resp = ui.interact(r, egui::Id::new(("sib", *slug)), Sense::click());
p.rect_filled(r, 12.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x24, 0x26, 0x2e) });
p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.5), t.text);
app.auto.add(&format!("home.sibling.{slug}"), r, name);
if resp.clicked() {
let _ = app.session.execute("help.sibling", json!({"app": slug}));
}
}
let community_bottom = cy + sib.len().div_ceil(per_row) as f32 * 28.0;
// ---- recent projects.
let (rx, mut ry, rw) = if wide {
let rx = x0 + left_w + 40.0;
(rx, rect.min.y + pad + 6.0, rect.max.x - pad - rx)
} else {
(x0, community_bottom + 24.0, left_w)
};
p.text(pos2(rx, ry + 6.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(16.0), Color32::WHITE);
let entries = recent_entries(&app.session.prefs);
if !entries.is_empty() {
let cr = Rect::from_min_size(pos2(rx + rw - 100.0, ry - 4.0), vec2(100.0, 20.0));
let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click());
p.text(cr.right_center(), Align2::RIGHT_CENTER, "Clear list", Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_faint });
app.auto.add("home.clearRecent", cr, "Clear Recent Projects");
if resp.clicked() {
actions.push(("file.clearRecent", json!({})));
}
}
ry += 26.0;
// Column headings.
let date_w = 130.0;
p.text(pos2(rx + 112.0, ry), Align2::LEFT_CENTER, "Name", Tokens::ui(11.0), t.text_faint);
p.text(pos2(rx + rw - date_w, ry), Align2::LEFT_CENTER, "Modified", Tokens::ui(11.0), t.text_faint);
ry += 10.0;
p.line_segment([pos2(rx, ry), pos2(rx + rw, ry)], Stroke::new(1.0, t.separator));
ry += 6.0;
if entries.is_empty() {
p.text(pos2(rx, ry + 20.0), Align2::LEFT_CENTER, "No recent projects. Projects you open or save appear here.", Tokens::ui(12.0), t.text_faint);
}
let row_h = 66.0;
for e in &entries {
let r = Rect::from_min_size(pos2(rx, ry), vec2(rw, row_h - 4.0));
if r.min.y > rect.max.y {
break;
}
let resp = ui.interact(r, egui::Id::new(("home-recent", e.index)), Sense::click()).on_hover_text(&e.path);
if resp.hovered() {
p.rect_filled(r, 6.0, t.hover);
}
let tr = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(THUMB_W as f32, THUMB_H as f32));
match thumb_texture(app, &ctx, &e.path) {
Some(tex) => {
p.image(tex.id(), tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
p.rect_filled(tr, 3.0, Color32::from_rgb(0x24, 0x26, 0x2e));
icons::paint(&p, Rect::from_center_size(tr.center(), vec2(20.0, 20.0)), Icon::Comp, t.text_faint);
}
}
p.rect_stroke(tr, 3.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
let tx = tr.max.x + 12.0;
let text_clip = p.with_clip_rect(Rect::from_min_max(pos2(tx, r.min.y), pos2(r.max.x - date_w - 8.0, r.max.y)));
text_clip.text(pos2(tx, r.min.y + 20.0), Align2::LEFT_CENTER, &e.name, Tokens::semibold(13.0), if e.exists { Color32::WHITE } else { t.text_dim });
let sub = if e.exists { e.folder.clone() } else { format!("{} (missing)", e.folder) };
text_clip.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint);
if let Some(d) = &e.modified {
p.text(pos2(r.max.x - date_w, r.min.y + 20.0), Align2::LEFT_CENTER, d, Tokens::ui(11.0), t.text_dim);
}
app.auto.add(&format!("home.recent.{}", e.index), r, &e.path);
if resp.clicked() {
actions.push(("file.openRecent", json!({"index": e.index})));
}
ry += row_h;
}
for (id, params) in actions {
let before = (app.session.path.clone(), app.session.revision);
match crate::menus::invoke(app, &ctx, id, params) {
Err(e) => app.ui.status = e,
// Leave Home unless only the list changed or a file dialog was cancelled.
Ok(_) => {
let cancelled = matches!(id, "file.open" | "file.import") && (app.session.path.clone(), app.session.revision) == before;
if id != "file.clearRecent" && !cancelled {
app.ui.start_screen = false;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recent_list_comes_from_settings() {
let mut prefs = effectcraft_engine::prefs::Prefs::default();
prefs.push_recent("/projects/a/Alpha.ecproj");
prefs.push_recent("/projects/b/Beta.ecproj");
let e = recent_entries(&prefs);
assert_eq!(e.len(), 2);
assert_eq!((e[0].name.as_str(), e[0].folder.as_str(), e[0].index), ("Beta", "/projects/b", 0));
assert_eq!(e[1].name, "Alpha");
assert!(!e[0].exists && e[0].modified.is_none());
// A real file has a date.
let dir = std::env::temp_dir().join(format!("ec-home-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("Real.ecproj");
std::fs::write(&f, "{}").unwrap();
prefs.push_recent(&f.to_string_lossy());
let e = recent_entries(&prefs);
assert!(e[0].exists && e[0].modified.as_ref().is_some_and(|d| d.len() == 16), "{:?}", e[0]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn dates_and_thumbnails() {
assert_eq!(format_utc(0), "1970-01-01 00:00");
assert_eq!(format_utc(1_790_000_000), "2026-09-21 14:13");
assert_eq!(format_utc(951_782_400), "2000-02-29 00:00");
let img = egui::ColorImage::new([200, 100], vec![Color32::from_rgb(10, 200, 30); 200 * 100]);
let text = encode_thumb(&img);
let back = decode_thumb(&text).unwrap();
assert_eq!(back.size, [THUMB_W, THUMB_H]);
// Letterboxed: the middle is the image, the top row is the background (2:1 into 16:9).
assert_eq!(back.pixels[THUMB_H / 2 * THUMB_W + THUMB_W / 2], Color32::from_rgb(10, 200, 30));
assert_ne!(back.pixels[THUMB_W / 2], Color32::from_rgb(10, 200, 30));
assert!(decode_thumb("abc").is_none());
assert_ne!(thumb_name("/a.ecproj"), thumb_name("/b.ecproj"));
}
#[test]
fn home_screen_registers_its_controls() {
let mut s = effectcraft_engine::Session::default();
s.prefs.push_recent("/projects/Alpha.ecproj");
let mut app = EffectcraftApp::new(s);
let ctx = egui::Context::default();
crate::theme::install(&ctx, &app.tokens);
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
show(&mut app, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)));
})
.textures_delta
.clear();
for id in [
"home.file.newProject",
"home.file.open",
"home.app.newComp",
"home.file.openDemoProject",
"home.help.discord",
"home.help.github",
"home.recent.0",
] {
assert!(app.auto.find(id).is_some(), "{id}");
}
}
}

View File

@ -1,10 +1,10 @@
//! Smaller panels: Preview, Audio, History, Markers, the Home screen and placeholders.
//! Smaller panels: Preview, Audio, History, Markers and placeholders.
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use crate::dock::PanelKind;
use crate::icons::{self, Icon};
use crate::icons::Icon;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
@ -223,73 +223,3 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
/// Home screen: new/open, recent, and community links.
pub fn start_screen(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, Color32::from_rgb(0x1b, 0x1d, 0x24));
let c = rect.center();
let logo = Rect::from_center_size(c + vec2(0.0, -150.0), vec2(64.0, 64.0));
crate::header::paint_logo(&p, logo);
p.text(c + vec2(0.0, -96.0), Align2::CENTER_CENTER, "EffectCraft", Tokens::semibold(28.0), Color32::WHITE);
p.text(c + vec2(0.0, -66.0), Align2::CENTER_CENTER, "Motion graphics and visual effects, in pure Rust.", Tokens::ui(13.0), t.text_dim);
let mut y = c.y - 30.0;
for (label, id, primary) in [
("New Composition", "app.newComp", true),
("Open Demo Project", "file.openDemoProject", false),
("Open Project…", "file.open", false),
("Import Footage…", "file.import", false),
] {
let r = Rect::from_center_size(pos2(c.x, y + 16.0), vec2(240.0, 32.0));
if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() {
let _ = crate::menus::invoke(app, &ctx, id, json!({}));
app.ui.start_screen = false;
}
app.auto.add(&format!("home.{id}"), r, label);
y += 40.0;
}
// Community.
y += 14.0;
let links = [(Icon::Chat, "Join the Discord", "help.discord"), (Icon::Globe, "getartcraft.com", "help.website"), (Icon::Code, "GitHub", "help.github")];
let w = 170.0;
let mut x = c.x - w * 1.5 - 8.0;
for (icon, label, cmd) in links {
let r = Rect::from_min_size(pos2(x, y), vec2(w, 30.0));
let resp = ui.interact(r, egui::Id::new(("home-link", cmd)), Sense::click());
let discord = cmd == "help.discord";
let bg = if discord {
Color32::from_rgb(0x58, 0x65, 0xf2)
} else if resp.hovered() {
t.hover
} else {
Color32::from_rgb(0x2a, 0x2c, 0x34)
};
p.rect_filled(r, 15.0, bg);
icons::paint(&p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(14.0, 14.0)), icon, Color32::WHITE);
p.text(pos2(r.min.x + 34.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), Color32::WHITE);
app.auto.add(&format!("home.{cmd}"), r, label);
if resp.clicked() {
let _ = app.session.execute(cmd, json!({}));
}
x += w + 8.0;
}
// Siblings.
y += 52.0;
p.text(pos2(c.x, y), Align2::CENTER_CENTER, "More ArtCraft apps", Tokens::ui(11.5), t.text_faint);
y += 18.0;
let sib = effectcraft_engine::links::SIBLINGS;
let sw = 104.0;
let mut x = c.x - sw * sib.len() as f32 / 2.0;
for (name, slug) in sib {
let r = Rect::from_min_size(pos2(x + 2.0, y), vec2(sw - 4.0, 24.0));
let resp = ui.interact(r, egui::Id::new(("sib", *slug)), Sense::click());
p.rect_filled(r, 12.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x24, 0x26, 0x2e) });
p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.5), t.text);
if resp.clicked() {
let _ = app.session.execute("help.sibling", json!({"app": slug}));
}
x += sw;
}
}

View File

@ -6,10 +6,12 @@ pub mod dialogs;
pub mod dialogs_3d;
pub mod effect_controls;
pub mod effects_presets;
pub mod flowchart;
pub mod forms;
pub mod fx_widgets;
pub mod graph;
pub mod graph_tools;
pub mod home;
pub mod info;
pub mod key_dialogs;
pub mod layer_panel;
@ -65,7 +67,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
match p {
PanelKind::Composition => {
if app.ui.start_screen {
misc::start_screen(app, ui, rect);
home::show(app, ui, rect);
} else {
viewer::show(app, ui, rect)
}
@ -93,7 +95,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::Layer => layer_panel::show(app, ui, rect),
PanelKind::Paint => paint_panels::paint(app, ui, rect),
PanelKind::Brushes => paint_panels::brushes(app, ui, rect),
other => misc::placeholder(app, ui, rect, other),
PanelKind::Flowchart => flowchart::show(app, ui, rect),
}
}

File diff suppressed because it is too large Load Diff

View File

@ -375,6 +375,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 {
@ -406,6 +418,8 @@ impl Default for TimelineState {
graph_reference: false,
graph_transform_box: true,
expr_closed: BTreeSet::new(),
columns: default_tl_columns(),
source_name: false,
}
}
}
@ -588,6 +602,9 @@ pub struct UiState {
/// Composition Mini-Flowchart popup position (open when set).
#[serde(default)]
pub mini_flowchart: Option<[f32; 2]>,
/// Flowchart panel options (layers, effects, solids, direction, root comp).
#[serde(default)]
pub flowchart: crate::panels::flowchart::FlowOptions,
}
impl Default for UiState {
@ -639,6 +656,7 @@ impl Default for UiState {
layer_view: None,
anim_tools: AnimToolsState::default(),
mini_flowchart: None,
flowchart: Default::default(),
}
}
}

View File

@ -27,6 +27,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params
|---|---|---|
| `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. |
| `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). |
| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.revealAdd.<position\|scale\|rotation\|opacity\|anchor\|effects\|masks\|feather\|levels\|animated>`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). |
| `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` |
| `ui.menu.list` | `{}` | The menu tree with enablement. |
| `render.frame` | `{comp?, time?, max_side?, path?, base64?}` | Renders a comp frame through the session, independent of the viewer zoom or resolution. `comp` is an id or name (default: the active comp), `time` is in comp seconds (default: the CTI), `max_side` caps the longest side (0 = full size). Writes a PNG to `path` (default: a temp file) and returns `{comp, time, width, height, path}`. With `base64: true` it returns `{…, png: "<base64>"}` and writes no file. |

View File

@ -75,9 +75,10 @@ library groundwork for mask tracking and Warp Stabilizer.
What is left is concentrated in large systems: the 3D camera tracker,
the Warp Stabilizer and mask-tracking UI on top of
the new tracking library, SVG/PSD import, WebM, a disk cache,
native macOS menus, and wasm threads. (The missing effect categories, 3D Channel, Immersive Video
and OCIO, landed in M9.11; the scripting object model in M14.4; Roto Brush and Refine Edge in M6.7.)
the new tracking library, SVG/PSD import, WebM, a disk cache, and wasm threads. (The missing
effect categories, 3D Channel, Immersive Video and OCIO, landed in M9.11; the scripting object
model in M14.4; Roto Brush and Refine Edge in M6.7; native macOS menus, the Composition Flowchart,
Timeline column/search depth, the Home screen and every After Effects workspace in the UI-polish wave.)
## By area
@ -89,13 +90,13 @@ and OCIO, landed in M9.11; the scripting object model in M14.4; Roto Brush and R
| Import | 78% | 4.5 | PSD, SVG as shapes, Lottie |
| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets |
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
| Compositions | 72% | 6.0 | Essential Graphics panel (the marker dialog and Composition Flowchart landed) |
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
| Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
| Web | 60% | 2.5 | threads, browser storage, audio |
| 3D | 72% | 12.0 | 3D camera tracker, multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
| Effects | ≈ 75% | 5.0 | GPU versions of more effects and the 3D Camera Tracker (every other After Effects effect exists since M9.11 and M12.5) |
| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
| Masks & roto | 68% | 6.0 | variable-width mask feather points, Roto Brush's learned (3.0) segmentation model (mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
@ -134,7 +135,7 @@ Mocha and Cineware are third-party and not counted.
7. Point tracking and stabilization (in progress).
8. Frame blending, collapse transformations, slip edit.
9. A real 8/16/32-bit pipeline with linear blending and colour management.
10. Drag-to-dock and floating panels, saved workspaces, native macOS menus.
10. ~~Drag-to-dock and floating panels, saved workspaces, native macOS menus.~~
11. Auto-save, crash recovery, recent projects (in progress).
12. Expression gaps: `sampleImage`, `footage()` (the `sourceText` style API landed in M9.9).
13. Lottie (in progress), WebM, SVG and PSD import.