From 8404fead60a35f3007f6fe1dae80530d48716a21 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:21:08 -0400 Subject: [PATCH] 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, } } }