diff --git a/Cargo.lock b/Cargo.lock index 0af4fb66..0d3ae06b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 6b34763f..3d5e1bb1 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -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"] } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 34b40a84..b6426f5f 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -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). diff --git a/apps/effectcraft/src/native_menu.rs b/apps/effectcraft/src/native_menu.rs new file mode 100644 index 00000000..a20b27f5 --- /dev/null +++ b/apps/effectcraft/src/native_menu.rs @@ -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 { + s.parse().ok() +} + +pub struct NativeBar { + menu: Option, + items: HashMap, + spec: NativeMenu, + structure: u64, + state: Option, + events: Receiver, + /// The in-window-bar setting last applied (None: not yet). + in_window: Option, +} + +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 { + 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()); + } +} diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 39107f25..f0068ae9 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -26,6 +26,9 @@ pub fn specs() -> Vec { ), 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), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ac728200..7b44096e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -433,6 +433,20 @@ fn rename(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// The Timeline's Comment column. +fn set_comment(s: &mut Session, p: &Value) -> Result { + 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 { 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 { 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", diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 8b244bf6..11239de3 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -17,9 +17,6 @@ macro_rules! stub { pub fn specs() -> Vec { 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). diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 065d1762..0191c6ea 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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 diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ec06b3f0..5a2afab8 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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 { 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 { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d1a40946..cd052c3e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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 { + 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 = 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 = 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"); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 71cb9f45..996d9cba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 Option>>, /// Save dialog for other file kinds: (default name, extension). pub pick_save_file: Option Option>>, + /// The system clipboard's text (native menu Edit ▸ Paste into a text field). + pub clipboard_text: Option Option>>, + /// 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 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>, + /// 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); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 41188338..482bf4cd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 = 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, ¶ms), + "timeline.search" => { + app.ui.timeline.search = params.get("query").and_then(Value::as_str).unwrap_or_default().to_string(); + app.show_panel(PanelKind::Timeline); + } "timeline.graphEditor" => app.ui.timeline.graph_editor = !app.ui.timeline.graph_editor, "timeline.switchesModes" => app.ui.timeline.show_modes = !app.ui.timeline.show_modes, "timeline.collapseAll" => { @@ -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 { +pub(crate) fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option { 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 { +pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option { 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 { } } -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; } diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs new file mode 100644 index 00000000..ef5070b3 --- /dev/null +++ b/crates/ui-egui/src/native_menu.rs @@ -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, + }, + /// 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, + /// The menu key equivalent in `muda` accelerator notation, when the shortcut can be one (see + /// [`accelerator`]). + pub accelerator: Option, + pub enabled: bool, + pub checked: Option, +} + +/// 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, +} + +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) { + 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 { + 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 = 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::().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) -> Option> { + 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 { + 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) { + 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()); + } +} diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs new file mode 100644 index 00000000..2ba345cf --- /dev/null +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -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, + #[serde(skip)] + pub pan: [f32; 2], + /// Selected node id. + #[serde(skip)] + pub selected: Option, +} + +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:`, `layer::` or `fx:::`. + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub comp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub layer: Option, + /// Distance from the root (column, or row when vertical). + pub depth: usize, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct FlowGraph { + pub nodes: Vec, + /// (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, + index: BTreeMap, + edges: Vec<(String, String)>, + stack: BTreeSet, + } + 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 { + 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 = 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 { + 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 { + 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())); + } +} diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs new file mode 100644 index 00000000..42ded4b7 --- /dev/null +++ b/crates/ui-egui/src/panels/home.rs @@ -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-.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, + pub exists: bool, +} + +/// The recent projects, most recent first (Settings ▸ General ▸ recent items). +pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec { + 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 { + let t = text.trim(); + if t.len() != THUMB_W * THUMB_H * 6 { + return None; + } + let b: Vec = (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16)).collect::>().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 { + 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}"); + } + } +} diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 4d8e1e24..69d95f66 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -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; - } -} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ebdfd620..e8c13dce 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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), } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index c9a42698..b9289e77 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -93,30 +93,207 @@ const LABEL_W: f32 = 20.0; const NUM_W: f32 = 26.0; const SW: f32 = 19.0; +/// The Timeline's columns in After Effects' order: (id, header label). Right-click a column +/// header to show or hide them; the name column is always shown. +pub const COLUMNS: [(&str, &str); 13] = [ + ("av", "A/V Features"), + ("keys", "Keys"), + ("label", "Label"), + ("num", "#"), + ("name", "Layer Name"), + ("comment", "Comment"), + ("switches", "Switches"), + ("modes", "Modes"), + ("parent", "Parent & Link"), + ("in", "In"), + ("out", "Out"), + ("duration", "Duration"), + ("stretch", "Stretch"), +]; + +/// Is a column shown? +pub fn column_visible(tl: &crate::state::TimelineState, id: &str) -> bool { + match id { + "name" => true, + "modes" => tl.show_modes, + _ => tl.columns.contains(id), + } +} + +/// Show or hide a column (`modes` is the Switches/Modes toggle, F4). +pub fn set_column(tl: &mut crate::state::TimelineState, id: &str, on: bool) -> Result<(), String> { + match id { + "name" if on => Ok(()), + "name" => Err("the name column can't be hidden".into()), + "modes" => { + tl.show_modes = on; + Ok(()) + } + _ if COLUMNS.iter().any(|c| c.0 == id) => { + if on { + tl.columns.insert(id.to_string()); + } else { + tl.columns.remove(id); + } + Ok(()) + } + _ => Err(format!("unknown column `{id}` (one of: {})", COLUMNS.map(|c| c.0).join(", "))), + } +} + +const KEYS_W: f32 = 56.0; +const COMMENT_W: f32 = 120.0; +const TIME_W: f32 = 72.0; +const STRETCH_W: f32 = 60.0; +const PARENT_W: f32 = 116.0; +const MODES_W: f32 = 92.0 + 112.0; + +#[derive(Clone, Copy, Default)] +struct Vis { + av: bool, + keys: bool, + label: bool, + num: bool, + comment: bool, + switches: bool, + modes: bool, + parent: bool, + in_: bool, + out: bool, + duration: bool, + stretch: bool, +} + +impl Vis { + fn of(tl: &crate::state::TimelineState) -> Vis { + let v = |id| column_visible(tl, id); + Vis { + av: v("av"), + keys: v("keys"), + label: v("label"), + num: v("num"), + comment: v("comment"), + switches: v("switches"), + modes: v("modes"), + parent: v("parent"), + in_: v("in"), + out: v("out"), + duration: v("duration"), + stretch: v("stretch"), + } + } + /// Width of every visible column but the name. + fn fixed(&self) -> f32 { + let w = |on: bool, w: f32| if on { w } else { 0.0 }; + w(self.av, AV_W) + + w(self.keys, KEYS_W) + + w(self.label, LABEL_W) + + w(self.num, NUM_W) + + w(self.comment, COMMENT_W) + + w(self.switches, SW * 8.0 + 6.0) + + w(self.modes, MODES_W) + + w(self.parent, PARENT_W) + + w(self.in_, TIME_W) + + w(self.out, TIME_W) + + w(self.duration, TIME_W) + + w(self.stretch, STRETCH_W) + } +} + struct Cols { + vis: Vis, av: f32, + keys: f32, label: f32, num: f32, name: f32, + /// End of the name column. + name_end: f32, + comment: f32, switches: f32, mode: f32, trkmat: f32, parent: f32, + in_: f32, + out: f32, + duration: f32, + stretch: f32, end: f32, } -fn cols(x0: f32, width: f32, show_modes: bool) -> Cols { - let fixed = AV_W + LABEL_W + NUM_W + SW * 8.0 + if show_modes { 92.0 + 112.0 } else { 0.0 } + 116.0; - let name_w = (width - fixed).max(120.0); - let av = x0; - let label = av + AV_W; - let num = label + LABEL_W; - let name = num + NUM_W; - let switches = name + name_w; - let mode = switches + SW * 8.0 + 6.0; - let trkmat = mode + if show_modes { 92.0 } else { 0.0 }; - let parent = trkmat + if show_modes { 112.0 } else { 0.0 }; - Cols { av, label, num, name, switches, mode, trkmat, parent, end: parent + 116.0 } +fn cols(x0: f32, width: f32, vis: Vis) -> Cols { + let name_w = (width - vis.fixed()).max(120.0); + let mut x = x0; + let mut next = |on: bool, w: f32| { + let at = x; + if on { + x += w; + } + at + }; + let av = next(vis.av, AV_W); + let keys = next(vis.keys, KEYS_W); + let label = next(vis.label, LABEL_W); + let num = next(vis.num, NUM_W); + let name = next(true, name_w); + let comment = next(vis.comment, COMMENT_W); + let switches = next(vis.switches, SW * 8.0 + 6.0); + let mode = next(vis.modes, 92.0); + let trkmat = next(vis.modes, 112.0); + let parent = next(vis.parent, PARENT_W); + let in_ = next(vis.in_, TIME_W); + let out = next(vis.out, TIME_W); + let duration = next(vis.duration, TIME_W); + let stretch = next(vis.stretch, STRETCH_W); + let end = next(true, 0.0); + Cols { vis, av, keys, label, num, name, name_end: name + name_w, comment, switches, mode, trkmat, parent, in_, out, duration, stretch, end } +} + +/// Which column a header x position falls in. +fn column_at(cw: &Cols, x: f32) -> Option<&'static str> { + let v = cw.vis; + let spans = [ + ("av", v.av, cw.av, cw.av + AV_W), + ("keys", v.keys, cw.keys, cw.keys + KEYS_W), + ("label", v.label, cw.label, cw.label + LABEL_W), + ("num", v.num, cw.num, cw.num + NUM_W), + ("name", true, cw.name, cw.name_end), + ("comment", v.comment, cw.comment, cw.comment + COMMENT_W), + ("switches", v.switches, cw.switches, cw.switches + SW * 8.0 + 6.0), + ("modes", v.modes, cw.mode, cw.mode + MODES_W), + ("parent", v.parent, cw.parent, cw.parent + PARENT_W), + ("in", v.in_, cw.in_, cw.in_ + TIME_W), + ("out", v.out, cw.out, cw.out + TIME_W), + ("duration", v.duration, cw.duration, cw.duration + TIME_W), + ("stretch", v.stretch, cw.stretch, cw.stretch + STRETCH_W), + ]; + spans.into_iter().find(|(_, on, a, b)| *on && x >= *a && x < *b).map(|s| s.0) +} + +/// Snap a time to the nearest candidate within `tolerance` (all seconds); Shift-dragging the +/// current-time indicator snaps to keyframes, layer in/out points, markers and the work area. +pub fn snap_time(candidates: &[f64], t: f64, tolerance: f64) -> f64 { + candidates.iter().copied().filter(|c| (c - t).abs() <= tolerance).min_by(|a, b| (a - t).abs().total_cmp(&(b - t).abs())).unwrap_or(t) +} + +/// Times the current-time indicator snaps to (comp seconds). +fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec { + let mut v = vec![0.0, comp.work_area.0.seconds(), comp.work_area.1.seconds(), comp.duration.seconds()]; + v.extend(comp.markers.iter().map(|m| m.time.seconds())); + for l in &comp.layers { + v.push(l.in_point.seconds()); + v.push(l.out_point.seconds()); + v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds())); + } + for r in rows { + if let RowKind::Prop { uid } = r.kind + && let Some(l) = comp.layer(r.layer) + && let Some(p) = l.props.find(uid) + { + v.extend(p.keys.iter().map(|k| l.comp_time(k.time).seconds())); + } + } + v } /// Timeline horizontal mapping. @@ -222,127 +399,106 @@ fn reveal_matches(p: &Property, path_matches: &[&str], kind: &str) -> bool { } } +/// The name shown for a layer: its name, or its source's name (Source Name column). +fn display_name(app: &EffectcraftApp, l: &Layer) -> String { + if app.ui.timeline.source_name + && let Some(item) = l.source.item().and_then(|i| app.session.project.item(i)) + { + return item.name.clone(); + } + l.name.clone() +} + +/// The timeline search (After Effects' search field): rows for the properties and groups of a +/// layer whose names contain `q` (lowercase), each with its enclosing groups. A matching group +/// brings its whole contents. +fn search_rows(l: &Layer, q: &str) -> Vec { + fn group_row(l: &Layer, g: &PropGroup, depth: usize, parent: &PropGroup) -> Row { + let fx = matches!(g.kind, GroupKind::Effect { .. }).then_some(g.enabled); + let eye = + (parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING).then_some(g.enabled); + Row { layer: l.id, depth, kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: true, has_children: !g.children.is_empty(), fx, eye } } + } + fn all(l: &Layer, g: &PropGroup, depth: usize, out: &mut Vec) { + for c in &g.children { + match c { + Node::Group(sg) => { + out.push(group_row(l, sg, depth, g)); + all(l, sg, depth + 1, out); + } + Node::Prop(p) if prop_visible(p, l) => out.push(Row { layer: l.id, depth, kind: RowKind::Prop { uid: p.uid } }), + _ => {} + } + } + } + fn rec(l: &Layer, g: &PropGroup, depth: usize, q: &str, out: &mut Vec) { + for c in &g.children { + match c { + Node::Group(sg) if depth > 1 || group_visible(sg, l) => { + if sg.name.to_lowercase().contains(q) { + out.push(group_row(l, sg, depth, g)); + all(l, sg, depth + 1, out); + continue; + } + let mut inner = vec![]; + rec(l, sg, depth + 1, q, &mut inner); + if !inner.is_empty() { + out.push(group_row(l, sg, depth, g)); + out.extend(inner); + } + } + Node::Prop(p) if prop_visible(p, l) && p.name.to_lowercase().contains(q) => { + out.push(Row { layer: l.id, depth, kind: RowKind::Prop { uid: p.uid } }); + } + _ => {} + } + } + } + let mut out = vec![]; + rec(l, &l.props, 1, q, &mut out); + out +} + +/// The property each reveal key shows (P, S, R, T, A, F, L; `animated` = keyframed or with an +/// expression): (group searched, property match ids). +fn reveal_targets(kind: &str) -> Option<(&'static str, &'static [&'static str])> { + Some(match kind { + "position" => ("transform", &["position", "positionX", "positionY", "positionZ"]), + "scale" => ("transform", &["scale"]), + "rotation" => ("transform", &["rotation", "rotationX", "rotationY", "orientation"]), + "opacity" => ("transform", &["opacity"]), + "anchor" => ("transform", &["anchor"]), + "feather" => ("masks", &["feather"]), + "levels" => ("audio", &["levels"]), + "animated" => ("", &[]), + _ => return None, + }) +} + fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { let tl = &app.ui.timeline; - let search = tl.search.to_lowercase(); + let search = tl.search.trim().to_lowercase(); let mut rows = Vec::new(); for l in &comp.layers { if comp.hide_shy && l.switches.shy { continue; } - if !search.is_empty() && !l.name.to_lowercase().contains(&search) { + if !search.is_empty() { + // Layers whose name or properties match; matching properties are revealed. + let matches = search_rows(l, &search); + if matches.is_empty() && !display_name(app, l).to_lowercase().contains(&search) { + continue; + } + rows.push(Row { layer: l.id, depth: 0, kind: RowKind::Layer }); + rows.extend(matches); continue; } rows.push(Row { layer: l.id, depth: 0, kind: RowKind::Layer }); if !tl.open_layers.contains(&l.id.0) { continue; } - if let Some(kind) = tl.reveal.first() { - let (group, props): (&str, Vec<&str>) = match kind.as_str() { - "position" => ("transform", vec!["position", "positionX", "positionY", "positionZ"]), - "scale" => ("transform", vec!["scale"]), - "rotation" => ("transform", vec!["rotation", "rotationX", "rotationY", "orientation"]), - "opacity" => ("transform", vec!["opacity"]), - "anchor" => ("transform", vec!["anchor"]), - "feather" => ("masks", vec!["feather"]), - "levels" => ("audio", vec!["levels"]), - _ => ("", vec![]), - }; - match kind.as_str() { - "waveform" => { - if let Some(item) = super::waveform::audio_item(&app.session.project, l) { - rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Waveform { item: item.0 } }); - } - } - "effects" => { - if let Some(fx) = l.effects() { - for g in fx.groups() { - rows.push(Row { - layer: l.id, - depth: 1, - kind: RowKind::Group { - uid: g.uid, - name: g.name.clone(), - open: tl.open_groups.contains(&g.uid), - has_children: true, - fx: Some(g.enabled), - eye: None, - }, - }); - if tl.open_groups.contains(&g.uid) { - push_group(&mut rows, l, g, 2, &tl.open_groups); - } - } - } - } - "masks" => { - if let Some(m) = l.masks() { - for g in m.groups() { - rows.push(Row { - layer: l.id, - depth: 1, - kind: RowKind::Group { - uid: g.uid, - name: g.name.clone(), - open: tl.open_groups.contains(&g.uid), - has_children: true, - fx: None, - eye: None, - }, - }); - if tl.open_groups.contains(&g.uid) { - push_group(&mut rows, l, g, 2, &tl.open_groups); - } - } - } - } - "props" => { - // Animation ▸ Reveal Properties…: the engine picked the uids. - fn groups_in<'a>(g: &'a PropGroup, set: &std::collections::BTreeSet, out: &mut Vec<&'a PropGroup>) { - for sg in g.groups() { - if set.contains(&sg.uid) { - out.push(sg); - } else { - groups_in(sg, set, out); - } - } - } - let mut groups = vec![]; - groups_in(&l.props, &tl.reveal_props, &mut groups); - for g in groups { - let open = tl.open_groups.contains(&g.uid); - rows.push(Row { - layer: l.id, - depth: 1, - kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open, has_children: !g.children.is_empty(), fx: None, eye: None }, - }); - if open { - push_group(&mut rows, l, g, 2, &tl.open_groups); - } - } - let mut found = vec![]; - collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && tl.reveal_props.contains(&p.uid)); - for uid in found { - rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }); - } - } - _ => { - let mut found = vec![]; - let root = if group.is_empty() { - Some(&l.props) - } else if group == "masks" { - l.masks() - } else { - l.props.sub(group) - }; - if let Some(g) = root { - collect_props(g, &mut found, &|p| prop_visible(p, l) && reveal_matches(p, &props, kind)); - } - for uid in found { - rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }); - } - } - } + if !tl.reveal.is_empty() { + reveal_rows(app, l, &mut rows); continue; } for c in &l.props.children { @@ -388,6 +544,95 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { rows } +/// Rows of a twirled-open layer under the reveal shortcuts (one, or several added with Shift): +/// masks, effects, the revealed properties in property-tree order, the Reveal Properties +/// selection, then the waveform. +fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec) { + let tl = &app.ui.timeline; + let has = |k: &str| tl.reveal.iter().any(|r| r == k); + let group_rows = |rows: &mut Vec, g: &PropGroup, fx: bool| { + for g in g.groups() { + rows.push(Row { + layer: l.id, + depth: 1, + kind: RowKind::Group { + uid: g.uid, + name: g.name.clone(), + open: tl.open_groups.contains(&g.uid), + has_children: true, + fx: fx.then_some(g.enabled), + eye: None, + }, + }); + if tl.open_groups.contains(&g.uid) { + push_group(rows, l, g, 2, &tl.open_groups); + } + } + }; + if has("masks") + && let Some(m) = l.masks() + { + group_rows(rows, m, false); + } + if has("effects") + && let Some(fx) = l.effects() + { + group_rows(rows, fx, true); + } + // P/S/R/T/A/F/L/animated: matching properties, in tree order, each once. + let mut wanted = std::collections::BTreeSet::new(); + for kind in &tl.reveal { + let Some((group, ids)) = reveal_targets(kind) else { continue }; + let root = match group { + "" => Some(&l.props), + "masks" => l.masks(), + g => l.props.sub(g), + }; + if let Some(g) = root { + let mut found = vec![]; + collect_props(g, &mut found, &|p| prop_visible(p, l) && reveal_matches(p, ids, kind)); + wanted.extend(found); + } + } + if !wanted.is_empty() { + let mut found = vec![]; + collect_props(&l.props, &mut found, &|p| wanted.contains(&p.uid)); + rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } })); + } + if has("props") { + // Animation ▸ Reveal Properties…: the engine picked the uids. + fn groups_in<'a>(g: &'a PropGroup, set: &std::collections::BTreeSet, out: &mut Vec<&'a PropGroup>) { + for sg in g.groups() { + if set.contains(&sg.uid) { + out.push(sg); + } else { + groups_in(sg, set, out); + } + } + } + let mut groups = vec![]; + groups_in(&l.props, &tl.reveal_props, &mut groups); + for g in groups { + let open = tl.open_groups.contains(&g.uid); + rows.push(Row { + layer: l.id, + depth: 1, + kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open, has_children: !g.children.is_empty(), fx: None, eye: None }, + }); + if open { + push_group(rows, l, g, 2, &tl.open_groups); + } + } + let mut found = vec![]; + collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && tl.reveal_props.contains(&p.uid) && !wanted.contains(&p.uid)); + rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } })); + } + if has("waveform") + && let Some(item) = super::waveform::audio_item(&app.session.project, l) + { + rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Waveform { item: item.0 } }); + } +} fn collect_props(g: &PropGroup, out: &mut Vec, f: &dyn Fn(&Property) -> bool) { for c in &g.children { match c { @@ -467,9 +712,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ruler_h = 34.0; let colhdr_h = 22.0; let footer_h = 24.0; - let fixed = AV_W + LABEL_W + NUM_W + SW * 8.0 + if app.ui.timeline.show_modes { 92.0 + 112.0 } else { 0.0 } + 116.0 + 6.0; - let left_w = (fixed + 190.0).clamp(420.0, (rect.width() * 0.62).max(420.0)); - let cw = cols(rect.min.x, left_w, app.ui.timeline.show_modes); + let vis = Vis::of(&app.ui.timeline); + let left_w = (vis.fixed() + 190.0).clamp(420.0, (rect.width() * 0.72).max(420.0)); + let cw = cols(rect.min.x, left_w, vis); let graph_x0 = rect.min.x + left_w + 1.0; let graph_x1 = rect.max.x - 10.0; ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0))); @@ -487,6 +732,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(Rect::from_min_max(pos2(graph_x0, top), rect.max), 0.0, t.tl_bg); p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg)); + let mut actions: Vec<(String, serde_json::Value)> = Vec::new(); // ---- header (left): time display, search, switches. let tc = crate::panels::timecode(&app.session, &comp, time); let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0)); @@ -636,36 +882,118 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if (rresp.dragged() || rresp.clicked()) && let Some(pt) = rresp.interact_pointer_pos() { - let tt = Tick::from_seconds_f64(tm.t(pt.x).max(0.0)); + let mut secs = tm.t(pt.x).max(0.0); + // Shift-drag: snap to keyframes, in/out points, markers and the work area (8 px). + if ui.input(|i| i.modifiers.shift) { + let cands = snap_candidates(&comp, &build_rows(app, &comp)); + secs = snap_time(&cands, secs, 8.0 / pps.max(1e-6)); + } + let tt = Tick::from_seconds_f64(secs); app.session.set_time(tt); app.stop(); } - // ---- column headers. + // ---- column headers (right-click: show/hide columns; click the name header: Source/Layer Name). let ch = Rect::from_min_max(pos2(rect.min.x, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h)); p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg); p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator)); let hy = ch.center().y; let hic = |icon: Icon, x: f32| icons::paint(&p, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim); - hic(Icon::Eye, cw.av + 10.0); - hic(Icon::Speaker, cw.av + 28.0); - hic(Icon::Solo, cw.av + 46.0); - hic(Icon::Lock, cw.av + 64.0); - icons::paint(&p, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); - p.text(pos2(cw.num + 6.0, hy), Align2::LEFT_CENTER, "#", Tokens::ui(11.0), t.text_dim); - p.text(pos2(cw.name + 22.0, hy), Align2::LEFT_CENTER, "Layer Name", Tokens::ui(11.0), t.text_dim); - for (i, icon) in - [Icon::Shy, Icon::Collapse, Icon::Quality, Icon::Fx, Icon::FrameBlend, Icon::MotionBlur, Icon::Adjustment, Icon::Cube].into_iter().enumerate() + let htext = |x: f32, s: &str| p.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim); + if vis.av { + hic(Icon::Eye, cw.av + 10.0); + hic(Icon::Speaker, cw.av + 28.0); + hic(Icon::Solo, cw.av + 46.0); + hic(Icon::Lock, cw.av + 64.0); + } + if vis.keys { + htext(cw.keys + 6.0, "Keys"); + } + if vis.label { + icons::paint(&p, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); + } + if vis.num { + htext(cw.num + 6.0, "#"); + } + let name_hdr = if app.ui.timeline.source_name { "Source Name" } else { "Layer Name" }; + htext(cw.name + 22.0, name_hdr); + if vis.comment { + htext(cw.comment + 6.0, "Comment"); + } + if vis.switches { + for (i, icon) in + [Icon::Shy, Icon::Collapse, Icon::Quality, Icon::Fx, Icon::FrameBlend, Icon::MotionBlur, Icon::Adjustment, Icon::Cube].into_iter().enumerate() + { + hic(icon, cw.switches + SW * i as f32 + SW / 2.0); + } + } + if vis.modes { + htext(cw.mode + 4.0, "Mode"); + htext(cw.mode + 74.0, "T"); + htext(cw.trkmat + 4.0, "Track Matte"); + } + if vis.parent { + icons::paint(&p, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim); + htext(cw.parent + 20.0, "Parent & Link"); + } + for (on, x, s) in [(vis.in_, cw.in_, "In"), (vis.out, cw.out, "Out"), (vis.duration, cw.duration, "Duration"), (vis.stretch, cw.stretch, "Stretch")] { + if on { + htext(x + 6.0, s); + } + } + let hdr_rect = Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)); + let hresp = ui.interact(hdr_rect, egui::Id::new("tl-colhdr"), Sense::click()); + app.auto.add("timeline.header", hdr_rect, "Columns (right-click)"); + for (id, label) in COLUMNS { + if let Some(x0) = match id { + "av" => vis.av.then_some((cw.av, AV_W)), + "keys" => vis.keys.then_some((cw.keys, KEYS_W)), + "label" => vis.label.then_some((cw.label, LABEL_W)), + "num" => vis.num.then_some((cw.num, NUM_W)), + "name" => Some((cw.name, cw.name_end - cw.name)), + "comment" => vis.comment.then_some((cw.comment, COMMENT_W)), + "switches" => vis.switches.then_some((cw.switches, SW * 8.0 + 6.0)), + "modes" => vis.modes.then_some((cw.mode, MODES_W)), + "parent" => vis.parent.then_some((cw.parent, PARENT_W)), + "in" => vis.in_.then_some((cw.in_, TIME_W)), + "out" => vis.out.then_some((cw.out, TIME_W)), + "duration" => vis.duration.then_some((cw.duration, TIME_W)), + _ => vis.stretch.then_some((cw.stretch, STRETCH_W)), + } { + app.auto.add(&format!("timeline.header.{id}"), Rect::from_min_size(pos2(x0.0, ch.min.y), vec2(x0.1, colhdr_h)), label); + } + } + if hresp.clicked() + && let Some(pt) = hresp.interact_pointer_pos() + && column_at(&cw, pt.x) == Some("name") { - hic(icon, cw.switches + SW * i as f32 + SW / 2.0); + app.ui.timeline.source_name = !app.ui.timeline.source_name; } - if app.ui.timeline.show_modes { - p.text(pos2(cw.mode + 4.0, hy), Align2::LEFT_CENTER, "Mode", Tokens::ui(11.0), t.text_dim); - p.text(pos2(cw.mode + 74.0, hy), Align2::LEFT_CENTER, "T", Tokens::ui(11.0), t.text_dim); - p.text(pos2(cw.trkmat + 4.0, hy), Align2::LEFT_CENTER, "Track Matte", Tokens::ui(11.0), t.text_dim); + if hresp.secondary_clicked() { + let under = hresp.interact_pointer_pos().and_then(|pt| column_at(&cw, pt.x)); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-colhdr-under"), under.unwrap_or(""))); } - icons::paint(&p, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim); - p.text(pos2(cw.parent + 20.0, hy), Align2::LEFT_CENTER, "Parent & Link", Tokens::ui(11.0), t.text_dim); + hresp.context_menu(|ui| { + let under: &str = ctx.data(|d| d.get_temp(egui::Id::new("tl-colhdr-under"))).unwrap_or(""); + if !under.is_empty() && under != "name" && ui.button("Hide This").clicked() { + actions.push(("timeline.column".into(), json!({"column": under, "visible": false}))); + ui.close(); + } + ui.menu_button("Columns", |ui| { + for (id, label) in COLUMNS { + let label = if id == "name" { if app.ui.timeline.source_name { "Source Name" } else { "Layer Name" } } else { label }; + let on = column_visible(&app.ui.timeline, id); + if ui.add_enabled(id != "name", egui::Button::new(label).selected(on)).clicked() { + actions.push(("timeline.column".into(), json!({"column": id, "visible": !on}))); + ui.close(); + } + } + }); + if ui.button(if app.ui.timeline.source_name { "Show Layer Name" } else { "Show Source Name" }).clicked() { + actions.push(("timeline.sourceName".into(), json!({}))); + ui.close(); + } + }); let _ = cw.end; // ---- rows. @@ -693,7 +1021,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = rows_rect.min.y - app.ui.timeline.scroll_y; let selected = app.session.state.selected_layers.clone(); let sel_keys = app.session.state.selected_keys.clone(); - let mut actions: Vec<(String, serde_json::Value)> = Vec::new(); let mut ui_actions: Vec = Vec::new(); let idx_of = |id: LayerId| comp.index_of(id).unwrap_or(0); let graph_on = app.ui.timeline.graph_editor; @@ -743,7 +1070,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ]; for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() { let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(17.0, 17.0)); - if !applicable { + if !applicable || !vis.av { continue; } lp.rect_stroke(br.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); @@ -754,19 +1081,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // Label swatch. - let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0)); - lp.rect_filled(lr, 2.0, t.label(layer.label)); - let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click()); - lresp.context_menu(|ui| { - for lab in effectcraft_engine::color::Label::ALL { - let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) }; - if ui.button(name).clicked() { - actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()}))); - ui.close(); + if vis.label { + let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0)); + lp.rect_filled(lr, 2.0, t.label(layer.label)); + let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click()); + lresp.context_menu(|ui| { + for lab in effectcraft_engine::color::Label::ALL { + let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) }; + if ui.button(name).clicked() { + actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()}))); + ui.close(); + } } - } - }); - lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim); + }); + } + if vis.num { + lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim); + } + layer_cells(app, ui, &lp, &cw, &comp, layer, r, cy, &mut actions); // Twirl + icon + name. let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0)); let open = app.ui.timeline.open_layers.contains(&layer.id.0); @@ -775,7 +1107,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl"); icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim); - let name_rect = Rect::from_min_max(pos2(cw.name + 34.0, r.min.y), pos2(cw.switches - 4.0, r.max.y)); + let name_rect = Rect::from_min_max(pos2(cw.name + 34.0, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); let rename_id = egui::Id::new("tl-rename"); let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id)); if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) { @@ -793,11 +1125,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { let name_col = if is_sel { Color32::WHITE } else { t.text }; let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect())); - lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, &layer.name, Tokens::ui(12.0), name_col); + lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col); } // Row click → select; double-click → rename; drag → reorder (later). + let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 }; let row_resp = - ui.interact(Rect::from_min_max(pos2(cw.num, r.min.y), pos2(cw.switches, r.max.y)), egui::Id::new(("row", layer.id.0)), Sense::click()); + ui.interact(Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)), egui::Id::new(("row", layer.id.0)), Sense::click()); app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name); if row_resp.clicked() { let m = ui.input(|i| i.modifiers); @@ -833,7 +1166,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { (Icon::Cube, layer.is_3d(), "threeD", layer.source.is_av() || matches!(layer.source, LayerSource::Null)), ]; for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() { - if !applicable { + if !applicable || !vis.switches { continue; } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); @@ -904,28 +1237,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // Parent. - let pw_rect = Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)); - let pw = ui.interact(pw_rect, egui::Id::new(("parent-whip", layer.id.0)), Sense::drag()).on_hover_text("Parent pick whip: drag onto a layer"); - icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim }); - app.auto.add(&format!("timeline.layer.{}.pickWhip", layer.id.0), pw_rect, "Parent pick whip"); - if pw.drag_started() { - let pw_state: PickWhip = (0, layer.id.0, 0, pw_rect.center()); - ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state)); - } - let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0)); - let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into()); - let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::new(("parent", layer.id.0))); - app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent"); - let pop = egui::Id::new(("par-pop", layer.id.0)); - if presp.clicked() { - widgets::open_popup(ui, pop); - } - let cands: Vec<&Layer> = comp.layers.iter().filter(|l| l.id != layer.id).collect(); - let mut popts = vec!["None".to_string(), "-".to_string()]; - popts.extend(cands.iter().map(|l| format!("{}. {}", idx_of(l.id), l.name))); - if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) { - let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) }; - actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par}))); + if vis.parent { + let pw_rect = Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)); + let pw = + ui.interact(pw_rect, egui::Id::new(("parent-whip", layer.id.0)), Sense::drag()).on_hover_text("Parent pick whip: drag onto a layer"); + icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim }); + app.auto.add(&format!("timeline.layer.{}.pickWhip", layer.id.0), pw_rect, "Parent pick whip"); + if pw.drag_started() { + let pw_state: PickWhip = (0, layer.id.0, 0, pw_rect.center()); + ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state)); + } + let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0)); + let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into()); + let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::new(("parent", layer.id.0))); + app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent"); + let pop = egui::Id::new(("par-pop", layer.id.0)); + if presp.clicked() { + widgets::open_popup(ui, pop); + } + let cands: Vec<&Layer> = comp.layers.iter().filter(|l| l.id != layer.id).collect(); + let mut popts = vec!["None".to_string(), "-".to_string()]; + popts.extend(cands.iter().map(|l| format!("{}. {}", idx_of(l.id), l.name))); + if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) { + let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) }; + actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par}))); + } } // Layer bar. ui.set_clip_rect(right_clip); @@ -1190,10 +1526,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; // Keyframe navigator (in the A/V column, like AE). - if prop.is_animated() { + // (in the Keys column when it is shown). + if prop.is_animated() && (vis.keys || vis.av) { let lt = layer.layer_time(time); let at_key = effectcraft_engine::keyframe::key_at(&prop.keys, lt).is_some(); - let nav_x = cw.av + 18.0; + let nav_x = if vis.keys { cw.keys + 12.0 } else { cw.av + 18.0 }; let prev = Rect::from_center_size(pos2(nav_x, cy), vec2(12.0, 14.0)); let mid = Rect::from_center_size(pos2(nav_x + 16.0, cy), vec2(14.0, 14.0)); let next = Rect::from_center_size(pos2(nav_x + 32.0, cy), vec2(12.0, 14.0)); @@ -1556,6 +1893,101 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The optional per-layer cells: Comment (double-click to edit), In / Out / Duration (drag to +/// change by frames), Stretch (click: Time Stretch dialog). +#[allow(clippy::too_many_arguments)] +fn layer_cells( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + lp: &egui::Painter, + cw: &Cols, + comp: &Comp, + layer: &Layer, + r: Rect, + cy: f32, + actions: &mut Vec<(String, serde_json::Value)>, +) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let lid = layer.id.0; + if cw.vis.comment { + let cr = Rect::from_min_max(pos2(cw.comment + 4.0, r.min.y + 1.0), pos2(cw.comment + COMMENT_W - 4.0, r.max.y - 1.0)); + let edit_id = egui::Id::new("tl-comment-edit"); + let editing: Option<(u64, String)> = ctx.data(|d| d.get_temp(edit_id)); + app.auto.add(&format!("timeline.layer.{lid}.comment"), cr, &layer.comment); + if let Some((el, mut buf)) = editing.filter(|(el, _)| *el == lid) { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cr)); + let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(11.5)).desired_width(cr.width())); + er.request_focus(); + if er.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && buf != layer.comment { + actions.push(("layer.setComment".into(), json!({"layers": [el], "comment": buf}))); + } + ctx.data_mut(|d| d.remove::<(u64, String)>(edit_id)); + } else { + ctx.data_mut(|d| d.insert_temp(edit_id, (el, buf))); + } + } else { + let resp = ui.interact(cr, egui::Id::new(("tl-comment", lid)), Sense::click()).on_hover_text("Double-click to edit the comment"); + lp.with_clip_rect(cr.intersect(lp.clip_rect())).text(pos2(cr.min.x + 2.0, cy), Align2::LEFT_CENTER, &layer.comment, Tokens::ui(11.5), t.text_dim); + if resp.double_clicked() { + ctx.data_mut(|d| d.insert_temp(edit_id, (lid, layer.comment.clone()))); + } + } + } + let fd = comp.frame_duration().seconds(); + let cells = [ + (cw.vis.in_, cw.in_, "in", layer.in_point.seconds()), + (cw.vis.out, cw.out, "out", layer.out_point.seconds()), + (cw.vis.duration, cw.duration, "duration", (layer.out_point - layer.in_point).seconds()), + ]; + for (on, x, kind, secs) in cells { + if !on { + continue; + } + let cr = Rect::from_min_max(pos2(x + 2.0, r.min.y + 1.0), pos2(x + TIME_W - 2.0, r.max.y - 1.0)); + let tc = crate::panels::timecode(&app.session, comp, Tick::from_seconds_f64(secs) - if kind == "duration" { comp.display_start } else { Tick(0) }); + let resp = ui.interact(cr, egui::Id::new(("tl-cell", kind, lid)), Sense::drag()).on_hover_text("Drag to change"); + lp.text(pos2(cr.min.x + 4.0, cy), Align2::LEFT_CENTER, &tc, Tokens::mono(11.0), if resp.hovered() || resp.dragged() { t.hot_text } else { t.timecode }); + app.auto.add(&format!("timeline.layer.{lid}.{kind}"), cr, &tc); + if resp.hovered() || resp.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + let acc_id = egui::Id::new(("tl-cell-acc", kind, lid)); + if resp.dragged() { + let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); + acc += resp.drag_delta().x; + let frames = (acc / 4.0).trunc() as i64; + if frames != 0 { + acc -= frames as f32 * 4.0; + let d = frames as f64 * fd; + let merge = format!("cell-{kind}-{lid}"); + let params = match kind { + "in" => json!({"layers": [lid], "in": (layer.in_point.seconds() + d).max(0.0), "merge": merge}), + _ => json!({"layers": [lid], "out": (layer.out_point.seconds() + d).max(layer.in_point.seconds() + fd), "merge": merge}), + }; + actions.push(("layer.timing".into(), params)); + } + ctx.data_mut(|d| d.insert_temp(acc_id, acc)); + } + if resp.drag_stopped() { + ctx.data_mut(|d| d.remove::(acc_id)); + actions.push(("__endMerge".into(), json!({}))); + } + } + if cw.vis.stretch { + let cr = Rect::from_min_max(pos2(cw.stretch + 2.0, r.min.y + 1.0), pos2(cw.stretch + STRETCH_W - 2.0, r.max.y - 1.0)); + let label = format!("{:.1}%", layer.stretch * 100.0); + let resp = ui.interact(cr, egui::Id::new(("tl-stretch", lid)), Sense::click()).on_hover_text("Time Stretch…"); + lp.text(pos2(cr.min.x + 4.0, cy), Align2::LEFT_CENTER, &label, Tokens::ui(11.5), if resp.hovered() { t.hot_text } else { t.text }); + app.auto.add(&format!("timeline.layer.{lid}.stretch"), cr, &label); + if resp.clicked() { + actions.push(("layer.select".into(), json!({"layers": [lid]}))); + actions.push(("layer.timeStretch".into(), json!({}))); + } + } +} + enum UiAct { ToggleLayer(u64, bool), ToggleGroup(u64), @@ -1856,3 +2288,136 @@ fn value_editor( } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A comp with two solids; "Box" has an animated Position and a Gaussian Blur. + fn app() -> EffectcraftApp { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 320, "height": 180})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 40, "height": 40})).unwrap(); + let comp = s.active_comp().unwrap(); + let bx = comp.layers.iter().find(|l| l.name == "Box").unwrap().id.0; + s.execute("layer.select", json!({"layers": [bx]})).unwrap(); + s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap(); + let pos = s.active_comp().unwrap().layer(LayerId(bx)).unwrap().transform().unwrap().get("position").unwrap().uid; + s.execute("prop.toggleAnimation", json!({"layer": bx, "prop": pos})).unwrap(); + EffectcraftApp::new(s) + } + + fn labels(app: &EffectcraftApp) -> Vec { + let comp = app.session.active_comp().unwrap().clone(); + build_rows(app, &comp) + .into_iter() + .map(|r| { + let l = comp.layer(r.layer).unwrap(); + match r.kind { + RowKind::Layer => l.name.clone(), + RowKind::Group { name, .. } => format!("{}>{name}", " ".repeat(r.depth)), + RowKind::Prop { uid } => format!("{}{}", " ".repeat(r.depth), l.props.find(uid).unwrap().name), + _ => "?".into(), + } + }) + .collect() + } + + #[test] + fn search_filters_layers_and_reveals_matching_properties() { + let mut app = app(); + app.ui.timeline.search = "opac".into(); + let rows = labels(&app); + // Both layers have Transform > Opacity; nothing else shows. + assert_eq!(rows.iter().filter(|r| r.trim() == "Opacity").count(), 2, "{rows:?}"); + assert!(rows.iter().any(|r| r.trim() == ">Transform"), "{rows:?}"); + assert!(!rows.iter().any(|r| r.trim() == "Position"), "{rows:?}"); + // A layer name match shows the layer alone; a group match brings its contents. + app.ui.timeline.search = "plate".into(); + assert_eq!(labels(&app), vec!["Plate".to_string()]); + app.ui.timeline.search = "GAUSSIAN".into(); + let rows = labels(&app); + assert_eq!(rows[0], "Box"); + assert!(rows.iter().any(|r| r.trim() == ">Effects") && rows.iter().any(|r| r.trim() == "Blurriness"), "{rows:?}"); + assert!(!rows.contains(&"Plate".to_string())); + // Nothing matches → no rows. + app.ui.timeline.search = "zzz".into(); + assert!(labels(&app).is_empty()); + // Through the command. + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "timeline.search", json!({"query": "scale"})).unwrap(); + assert!(labels(&app).iter().any(|r| r.trim() == "Scale")); + } + + #[test] + fn columns_toggle_and_layout() { + let mut app = app(); + let tl = &mut app.ui.timeline; + assert!(column_visible(tl, "switches") && !column_visible(tl, "comment") && !column_visible(tl, "modes")); + set_column(tl, "comment", true).unwrap(); + set_column(tl, "stretch", true).unwrap(); + set_column(tl, "keys", false).unwrap(); + set_column(tl, "label", false).unwrap(); + set_column(tl, "modes", true).unwrap(); + assert!(tl.show_modes && column_visible(tl, "comment") && !column_visible(tl, "label")); + assert!(set_column(tl, "name", false).is_err()); + assert!(set_column(tl, "bogus", true).is_err()); + let vis = Vis::of(tl); + let cw = cols(0.0, 1400.0, vis); + // Hidden columns take no room; visible ones are in After Effects' order. + assert_eq!(cw.keys, cw.label); + assert_eq!(cw.label, cw.num); + assert!(cw.name < cw.comment && cw.comment < cw.switches && cw.switches < cw.mode && cw.parent < cw.stretch); + assert_eq!(column_at(&cw, cw.comment + 5.0), Some("comment")); + assert_eq!(column_at(&cw, cw.name + 5.0), Some("name")); + assert_eq!(column_at(&cw, cw.stretch + 5.0), Some("stretch")); + // Through the command (as agents do). + let ctx = egui::Context::default(); + let r = crate::menus::invoke(&mut app, &ctx, "timeline.column", json!({"column": "duration"})).unwrap(); + assert!(r["columns"].as_array().unwrap().iter().any(|c| c == "duration")); + crate::menus::invoke(&mut app, &ctx, "timeline.column", json!({"column": "duration", "visible": false})).unwrap(); + assert!(!column_visible(&app.ui.timeline, "duration")); + crate::menus::invoke(&mut app, &ctx, "timeline.sourceName", json!({})).unwrap(); + assert!(app.ui.timeline.source_name); + } + + #[test] + fn reveal_keys_add_with_shift_and_uu_reveals_modified() { + let mut app = app(); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "timeline.reveal.opacity", json!({})).unwrap(); + assert_eq!(app.ui.timeline.reveal, vec!["opacity"]); + crate::menus::invoke(&mut app, &ctx, "timeline.revealAdd.scale", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "timeline.revealAdd.effects", json!({})).unwrap(); + assert_eq!(app.ui.timeline.reveal, vec!["opacity", "scale", "effects"]); + let rows = labels(&app); + assert!(rows.iter().any(|r| r.trim() == "Scale") && rows.iter().any(|r| r.trim() == "Opacity"), "{rows:?}"); + assert!(rows.iter().any(|r| r.trim() == ">Gaussian Blur"), "{rows:?}"); + // Tree order: Scale before Opacity. + let si = rows.iter().position(|r| r.trim() == "Scale").unwrap(); + let oi = rows.iter().position(|r| r.trim() == "Opacity").unwrap(); + assert!(si < oi); + // Shift+S again removes Scale. + crate::menus::invoke(&mut app, &ctx, "timeline.revealAdd.scale", json!({})).unwrap(); + assert_eq!(app.ui.timeline.reveal, vec!["opacity", "effects"]); + // U: keyframed properties (Position); UU (twice quickly): every modified property. + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + let n_keys = app.ui.timeline.reveal_props.len(); + assert_eq!(app.ui.timeline.reveal, vec!["props"]); + assert!(n_keys >= 1); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + assert!(app.ui.timeline.reveal_props.len() > n_keys, "UU reveals modified properties too"); + } + + #[test] + fn cti_snaps_to_the_nearest_candidate() { + assert_eq!(snap_time(&[0.0, 1.0, 2.0], 1.04, 0.1), 1.0); + assert_eq!(snap_time(&[0.0, 1.0, 2.0], 1.5, 0.1), 1.5); + assert_eq!(snap_time(&[1.0, 1.1], 1.06, 0.1), 1.1); + let app = app(); + let comp = app.session.active_comp().unwrap().clone(); + let c = snap_candidates(&comp, &build_rows(&app, &comp)); + assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0)); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c4e31202..2ead3c6a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -375,6 +375,18 @@ pub struct TimelineState { /// Properties whose inline expression editor is collapsed. #[serde(default)] pub expr_closed: BTreeSet, + /// 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, + /// 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 { + ["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(), } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 449a1898..7763ee03 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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.`, `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: ""}` and writes no file. | diff --git a/docs/parity.md b/docs/parity.md index 96744c5a..656587ce 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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.