From 8404fead60a35f3007f6fe1dae80530d48716a21 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:21:08 -0400 Subject: [PATCH 1/5] UI-8, UI-3: native macOS menu bar; timeline columns, search, reveal-add, UU, CTI snapping Native menu bar (apps/effectcraft, muda, macOS only): the engine's After Effects menu tree mirrored into NSMenu through a pure, serializable spec (ui-egui native_menu: submenus, separators, live labels, check marks, enabled state, Cmd/Ctrl/function-key shortcuts as accelerators, Open Recent). Hide / Hide Others / Show All are macOS' predefined items (app.hideOthers/app.showAll are real commands now, via NSApplication); Services added; About, Settings and Quit dispatch our commands. State is pushed only when the session revision, selection, history, settings or journal change; structural changes rebuild. Activations dispatch the same command ids as the in-window bar; Edit Cut/Copy/Paste/Select All/Undo/Redo act on a focused text field. Settings > Appearance > Use In-Window Menu Bar on macOS switches back. Timeline: column visibility from the column header's context menu (A/V Features, Keys, Label, #, Comment, Switches, Modes, Parent & Link, In, Out, Duration, Stretch; Source/Layer Name toggle), Comment editing (layer.setComment), draggable In/Out/Duration cells, Stretch opens Time Stretch; property search filters layers and reveals matching properties with their groups; Shift+P/S/R/T/A/E/M/F/L/U add to the revealed properties; UU reveals all modified properties; Shift-drag in the ruler snaps the CTI to keys, in/out points, markers and the work area. Commands: timeline.column, timeline.sourceName, timeline.search, timeline.revealAdd.*. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 ++ apps/effectcraft/Cargo.toml | 6 + apps/effectcraft/src/main.rs | 40 +- apps/effectcraft/src/native_menu.rs | 210 ++++++ crates/engine/src/commands/frontend.rs | 3 + crates/engine/src/commands/layer.rs | 15 + crates/engine/src/commands/stubs.rs | 3 - crates/engine/src/prefs.rs | 4 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/menus.rs | 113 ++- crates/ui-egui/src/native_menu.rs | 459 ++++++++++++ crates/ui-egui/src/panels/timeline.rs | 931 ++++++++++++++++++++----- crates/ui-egui/src/state.rs | 14 + 13 files changed, 1640 insertions(+), 206 deletions(-) create mode 100644 apps/effectcraft/src/native_menu.rs create mode 100644 crates/ui-egui/src/native_menu.rs diff --git a/Cargo.lock b/Cargo.lock index 871c9fd8..fb72a303 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", @@ -2619,6 +2632,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2826,6 +2849,25 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" 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/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/lib.rs b/crates/ui-egui/src/lib.rs index 071c1948..e903811d 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)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 22b2bd40..4c85162e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -59,6 +59,19 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("timeline.reveal.feather", "Reveal Mask Feather", [], Some("F")), uic!("timeline.reveal.levels", "Reveal Audio Levels (press twice: Waveform)", [], Some("L")), uic!("timeline.reveal.waveform", "Reveal Audio Waveform", [], None), + uic!("timeline.revealAdd.position", "Add Position to Revealed Properties", [], Some("Shift+P")), + uic!("timeline.revealAdd.scale", "Add Scale to Revealed Properties", [], Some("Shift+S")), + uic!("timeline.revealAdd.rotation", "Add Rotation to Revealed Properties", [], Some("Shift+R")), + uic!("timeline.revealAdd.opacity", "Add Opacity to Revealed Properties", [], Some("Shift+T")), + uic!("timeline.revealAdd.anchor", "Add Anchor Point to Revealed Properties", [], Some("Shift+A")), + uic!("timeline.revealAdd.effects", "Add Effects to Revealed Properties", [], Some("Shift+E")), + uic!("timeline.revealAdd.masks", "Add Masks to Revealed Properties", [], Some("Shift+M")), + uic!("timeline.revealAdd.feather", "Add Mask Feather to Revealed Properties", [], Some("Shift+F")), + uic!("timeline.revealAdd.levels", "Add Audio Levels to Revealed Properties", [], Some("Shift+L")), + uic!("timeline.revealAdd.animated", "Add Animated Properties to Revealed Properties", [], Some("Shift+U")), + uic!("timeline.column", "Show/Hide Timeline Column", [], None), + uic!("timeline.sourceName", "Source Name / Layer Name", [], None), + uic!("timeline.search", "Search Timeline", [], None), uic!("timeline.collapseAll", "Collapse All", [], Some("Cmd+`")), uic!("tool.selection", "Selection Tool", [], Some("V")), uic!("tool.hand", "Hand Tool", [], Some("H")), @@ -89,26 +102,42 @@ pub fn panel_command_id(p: PanelKind) -> String { format!("window.panel.{}", p.id()) } -fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64) { +/// The reveal shortcuts (P, S, R, T, A, E, M, F, L…). `add` (Shift+key) adds the property to +/// (or removes it from) those already revealed instead of replacing them. +pub fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64, add: bool) { // AE's double-press shortcuts: L then L again quickly reveals the Waveform (LL). let kind = match app.last_reveal.take() { Some((k, t)) if k == "levels" && kind == "levels" && now - t < 0.6 => "waveform", _ => kind, }; app.last_reveal = Some((kind.to_string(), now)); - let add = app.ui.timeline.reveal.contains(&kind.to_string()); - if add && app.ui.timeline.reveal.len() == 1 { - app.ui.timeline.reveal.clear(); - app.ui.timeline.open_layers.clear(); + let tl = &mut app.ui.timeline; + let present = tl.reveal.iter().any(|k| k == kind); + let sel: Vec = if app.session.state.selected_layers.is_empty() { + app.session.active_comp().map(|c| c.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default() + } else { + app.session.state.selected_layers.iter().map(|l| l.0).collect() + }; + if add { + if present { + tl.reveal.retain(|k| k != kind); + if tl.reveal.is_empty() { + tl.open_layers.clear(); + } + } else { + tl.reveal.retain(|k| k != "props" || !tl.reveal_props.is_empty()); + tl.reveal.push(kind.to_string()); + tl.open_layers.extend(sel); + } return; } - app.ui.timeline.reveal = vec![kind.to_string()]; - let sel = if app.session.state.selected_layers.is_empty() { - app.session.active_comp().map(|c| c.layers.iter().map(|l| l.id).collect()).unwrap_or_default() - } else { - app.session.state.selected_layers.clone() - }; - app.ui.timeline.open_layers = sel.iter().map(|l| l.0).collect(); + if present && tl.reveal.len() == 1 { + tl.reveal.clear(); + tl.open_layers.clear(); + return; + } + tl.reveal = vec![kind.to_string()]; + tl.open_layers = sel.into_iter().collect(); } fn no_params(p: &Value) -> bool { @@ -166,9 +195,30 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if k == "animated" { return run_engine(app, ctx, "anim.reveal", json!({"kind": "keyframes"})); } - reveal(app, k, now); + reveal(app, k, now, false); return Ok(Value::Null); } + if let Some(k) = id.strip_prefix("timeline.revealAdd.") { + reveal(app, k, now, true); + return Ok(json!({"revealed": app.ui.timeline.reveal})); + } + // U reveals keyframed properties; UU (pressed twice quickly) all modified properties; U + // again later hides them. + if id == "anim.reveal" && params.get("kind").and_then(Value::as_str) == Some("keyframes") { + let last = app.last_reveal.take(); + app.last_reveal = Some(("keyframes".into(), now)); + match last { + Some((k, t)) if k == "keyframes" && now - t < 0.6 => { + return run_engine(app, ctx, "anim.reveal", json!({"kind": "modified"})); + } + Some((k, _)) if k == "keyframes" && app.ui.timeline.reveal == ["props"] => { + app.ui.timeline.reveal.clear(); + app.ui.timeline.open_layers.clear(); + return Ok(json!({"revealed": 0})); + } + _ => {} + } + } // Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}. if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) { let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`"))); @@ -268,6 +318,23 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return Ok(Value::Null); } "timeline.zoomFit" => app.ui.timeline.pps = None, + "timeline.column" => { + let col = params.get("column").and_then(Value::as_str).ok_or("timeline.column: need `column`")?; + let on = params.get("visible").and_then(Value::as_bool).unwrap_or(!crate::panels::timeline::column_visible(&app.ui.timeline, col)); + crate::panels::timeline::set_column(&mut app.ui.timeline, col, on)?; + let shown: Vec<&str> = + crate::panels::timeline::COLUMNS.iter().map(|c| c.0).filter(|c| crate::panels::timeline::column_visible(&app.ui.timeline, c)).collect(); + return Ok(json!({"columns": shown})); + } + "timeline.sourceName" => { + let tl = &mut app.ui.timeline; + tl.source_name = params.get("value").and_then(Value::as_bool).unwrap_or(!tl.source_name); + return Ok(json!({"sourceName": tl.source_name})); + } + "timeline.search" => { + app.ui.timeline.search = params.get("query").and_then(Value::as_str).unwrap_or_default().to_string(); + app.show_panel(PanelKind::Timeline); + } "timeline.graphEditor" => app.ui.timeline.graph_editor = !app.ui.timeline.graph_editor, "timeline.switchesModes" => app.ui.timeline.show_modes = !app.ui.timeline.show_modes, "timeline.collapseAll" => { @@ -399,8 +466,16 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu ); Value::Null } - "app.hide" => { - ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + "app.hide" | "app.hideOthers" | "app.showAll" => { + // The desktop app asks macOS (NSApplication hide / hideOtherApplications / + // unhideAllApplications); elsewhere Hide minimizes and the others don't apply. + if app.hooks.app_action.as_ref().is_some_and(|f| f(id)) { + return Ok(Value::Null); + } + match id { + "app.hide" => ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)), + _ => return Err(format!("`{id}` is only available on macOS")), + } Value::Null } "app.quit" => { @@ -768,7 +843,7 @@ pub fn menus() -> Vec<&'static str> { effectcraft_engine::menus::top_level() } -fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String { +pub(crate) fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String { match e.command.as_str() { "edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()), "edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()), @@ -777,12 +852,12 @@ fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String { } /// The entry's shortcut in the active keyboard shortcut preset. -fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option { +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() { @@ -814,7 +889,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/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 d1350103..af59bb6b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -360,6 +360,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 { @@ -391,6 +403,8 @@ impl Default for TimelineState { graph_reference: false, graph_transform_box: true, expr_closed: BTreeSet::new(), + columns: default_tl_columns(), + source_name: false, } } } From a86b02901e7d3c9134f41968bc396bcd7ecbab71 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:26:14 -0400 Subject: [PATCH 2/5] UI-7: Home screen with recent projects, thumbnails and community links The Home screen (panels/home.rs) gets New Project / Open Project / New Composition / Open Demo Project / Import, the recent projects from Settings (File > Open Recent) with thumbnails, folder and modified date (click opens, Clear list), and the ArtCraft community links (Discord, getartcraft.com, the EffectCraft page, GitHub, sibling apps) where After Effects has Learn / What's New. A 96x54 thumbnail is stored in the config store when a project is opened or saved and the viewer shows it. Automation ids: home., home.recent., home.clearRecent, home.sibling.. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/panels/home.rs | 389 ++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/misc.rs | 72 +----- crates/ui-egui/src/panels/mod.rs | 3 +- 4 files changed, 399 insertions(+), 72 deletions(-) create mode 100644 crates/ui-egui/src/panels/home.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e903811d..59005cdc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -164,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 { @@ -208,6 +212,8 @@ impl EffectcraftApp { applied_prefs: None, recovery: None, dock_rects: vec![], + home_thumbs: Default::default(), + home_thumb_saved: None, } .with_ui_commands() } @@ -762,6 +768,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/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..0db1ed7f 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -4,7 +4,7 @@ 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 3f737502..dae89f16 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -10,6 +10,7 @@ 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; @@ -63,7 +64,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) } From 541c740f1859b7e9cf072b868590b07b0e7cea85 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:30:04 -0400 Subject: [PATCH 3/5] CMP-6: Flowchart panel Composition > Composition Flowchart charts the active comp as a node graph: nested comps, footage and solids, optionally the layers and their effects (panel switches Solids / Layers / Effects), left to right or top to bottom. Double-click a comp to open it, Cmd/Ctrl-click to reveal an item in the Project panel (a layer: select it), drag or scroll to pan. Reveal Layer in Project Flowchart shows layers with the layer selected. The graph builder is pure and cycle-safe (shared items drawn once, back edges to comps on the path). Commands flowchart.options {layers, effects, solids, direction, comp} and flowchart.graph (the graph as JSON); automation ids flowchart.node. and flowchart.option.. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 10 + crates/ui-egui/src/panels/flowchart.rs | 460 +++++++++++++++++++++++++ crates/ui-egui/src/panels/misc.rs | 2 +- crates/ui-egui/src/panels/mod.rs | 3 +- crates/ui-egui/src/state.rs | 4 + 5 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/panels/flowchart.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4c85162e..da5eb842 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -72,6 +72,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ 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")), @@ -331,6 +333,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V 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); @@ -712,6 +715,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 } 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/misc.rs b/crates/ui-egui/src/panels/misc.rs index 0db1ed7f..69d95f66 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -1,4 +1,4 @@ -//! 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; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index dae89f16..cc103e84 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -6,6 +6,7 @@ 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; @@ -91,7 +92,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/state.rs b/crates/ui-egui/src/state.rs index af59bb6b..1d1dcb23 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -584,6 +584,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 { @@ -634,6 +637,7 @@ impl Default for UiState { layer_view: None, anim_tools: AnimToolsState::default(), mini_flowchart: None, + flowchart: Default::default(), } } } From 6aca8fe85c9a2dd76fc02419e3270535ae313556 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:34:32 -0400 Subject: [PATCH 4/5] UI-1: every After Effects workspace Adds Review, Learn, Essential Graphics, Color and Undocked Panels to the built-in workspaces (Libraries, an Adobe service, is left out), lists them all in Window > Workspace in After Effects' order, and gives each its own layout: Review (large viewer, Markers and Project), Learn (Default plus the Home screen with the community links), Essential Graphics (Properties panel full height, type panels), Color (wide Effect Controls, Info), Undocked Panels (Composition docked, the other panels floating). Tests check every AE workspace exists, has its own layout, has each panel once and is in the menu. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/menus.rs | 15 +++-- crates/ui-egui/src/dock.rs | 125 ++++++++++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 6 +- 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 38252a88..14c39dbb 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/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 99bc7c5e..9d6d55cc 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -172,7 +172,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 { @@ -226,6 +262,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( @@ -814,8 +884,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 59005cdc..e1848063 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -279,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). From 5e441e859df4235427e865adb51044ad58654fa5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:36:30 -0400 Subject: [PATCH 5/5] UI-8, UI-3, UI-7, CMP-6, UI-1: parity and control-protocol docs Co-Authored-By: Claude Opus 5.5 --- docs/control-protocol.md | 1 + docs/parity.md | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) 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 aecb3f13..0be871b4 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -76,8 +76,9 @@ library groundwork for mask tracking and Warp Stabilizer. What is left is concentrated in large systems: Roto Brush and Refine Edge, the 3D camera tracker, the Warp Stabilizer and mask-tracking UI on top of the new tracking library, a JavaScript scripting object model, SVG/PSD import, WebM, a disk cache, -the missing effect categories (3D Channel, Immersive Video, OCIO), native macOS menus, and -wasm threads. +the missing effect categories (3D Channel, Immersive Video, OCIO), and wasm threads. (Native +macOS menus, the Composition Flowchart, Timeline column/search depth, the Home screen and every +After Effects workspace landed in the UI-polish wave.) ## By area @@ -89,13 +90,13 @@ wasm threads. | Import | 78% | 4.5 | PSD, SVG as shapes, Lottie | | Automation | 76% | 3.3 | a JavaScript scripting object model | | 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 | 58% | 13.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU versions of more effects, 41 missing effects | -| 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 | 52% | 10.0 | Roto Brush, variable-width mask feather points (mask tracking and Mask Interpolation landed in M6.6) | | Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) | @@ -122,7 +123,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.