From a3de99c5e510c10de01c52a110818c39394fc145 Mon Sep 17 00:00:00 2001 From: Jonas Neubauer <108294796+jonassneu@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:41:00 +0200 Subject: [PATCH] G2: add context menus to blank Project and Timeline areas --- crates/ui-egui/src/panels/project.rs | 14 +- crates/ui-egui/src/panels/timeline.rs | 33 +++++ .../ui-egui/tests/ui_blank_context_menus.rs | 133 ++++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/ui_blank_context_menus.rs diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 2f9963d9..8b8827dd 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -325,7 +325,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // The list's empty area (under the rows and the scroll bar, which take their own clicks): // a double-click imports, as in After Effects (File ▸ Import ▸ File...). let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click()); - app.auto.add("project.empty", list, "Double-click to import files"); + app.auto.add("project.empty", list, "Double-click to import files; right-click for project actions"); if overflow > 0.0 && ui.rect_contains_pointer(list) { let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift)); let d = if dx.abs() > 0.0 { @@ -718,6 +718,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if empty.double_clicked() { actions.push(("file.import".into(), json!({}))); } + empty.context_menu(|ui| { + for (key, label, command) in + [("newComp", "New Composition…", "comp.new"), ("newFolder", "New Folder", "project.newFolder"), ("import", "Import File…", "file.import")] + { + let response = ui.button(label); + app.auto.add(&format!("project.context.{key}"), response.rect, label); + if response.clicked() { + actions.push((command.into(), json!({}))); + ui.close(); + } + } + }); for (id, params) in actions { if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8af70d55..6b977f74 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2121,6 +2121,39 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } ui.set_clip_rect(full_clip); + // The background responses sit below rows, bars and editors, preserving their menus. + if !graph_on { + app.auto.add("timeline.empty", empty_rect, "Right-click to create a layer"); + } + for (response, area) in [(&outline_empty, "outline"), (&empty, "graph")] { + response.context_menu(|ui| { + let menu = ui.menu_button("New", |ui| { + for (key, label, command, params) in [ + ("text", "Text", "layer.newText", json!({"text": "Text"})), + ("solid", "Solid…", "layer.newSolid", json!({})), + ("light", "Light…", "layer.newLight", json!({})), + ("camera", "Camera…", "layer.newCamera", json!({})), + ("null", "Null Object", "layer.newNull", json!({})), + ("shape", "Shape Layer", "layer.newShape", json!({"kind": "none"})), + ("adjustment", "Adjustment Layer", "layer.newAdjustment", json!({})), + ] { + let response = ui.button(label); + app.auto.add(&format!("timeline.context.{area}.{key}"), response.rect, label); + if response.clicked() { + actions.push((command.into(), params)); + ui.close(); + } + } + }); + app.auto.add(&format!("timeline.context.{area}.new"), menu.response.rect, "New"); + let response = ui.button("Composition Settings…"); + app.auto.add(&format!("timeline.context.{area}.settings"), response.rect, "Composition Settings…"); + if response.clicked() { + actions.push(("comp.settings".into(), json!({}))); + ui.close(); + } + }); + } if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) { actions.push(("layer.select".into(), json!({"layers": []}))); } diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs new file mode 100644 index 00000000..415eddc7 --- /dev/null +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -0,0 +1,133 @@ +//! Right-click actions in the blank Project and Timeline areas. +use effectcraft_engine::Session; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable as _}; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut session = Session::default(); + session.execute("comp.new", json!({"name":"Empty", "width":640,"height":360,"duration":4})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(3); + h +} +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} +fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) { + if let Ok(dir) = std::env::var("BLANK_CONTEXT_MENU_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + } +} +fn right_click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +#[test] +fn project_blank_menu_creates_a_folder_and_undo_restores_the_project() { + let mut h = harness(); + let before = h.state().session.project.items.len(); + let p = rect(&h, "project.empty").center(); + right_click(&mut h, p); + snapshot(&mut h, "project"); + h.get_by_label("New Folder").click(); + h.run_steps(3); + assert_eq!(h.state().session.project.items.len(), before + 1); + assert!(h.state().session.project.items.values().any(|i| i.is_folder())); + assert!(h.query_by_label("New Folder").is_none()); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.project.items.len(), before); +} + +#[test] +fn project_blank_menu_opens_composition_settings_and_offers_import() { + let mut h = harness(); + let p = rect(&h, "project.empty").center(); + right_click(&mut h, p); + assert!(h.query_by_label("Import File…").is_some()); + h.get_by_label("New Composition…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::NewComp)); +} + +#[test] +fn timeline_blank_outline_and_time_graph_create_layers() { + for graph in [false, true] { + let mut h = harness(); + let area = rect(&h, "timeline.layerMarquee"); + let p = if graph { pos2(area.max.x + 150.0, area.center().y) } else { area.center() }; + right_click(&mut h, p); + h.get_by_label("New ⏵").click(); + h.run_steps(3); + snapshot(&mut h, if graph { "timeline-graph" } else { "timeline-outline" }); + h.get_by_label("Null Object").click(); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(h.state().session.active_comp().unwrap().layers.is_empty()); + } +} + +#[test] +fn timeline_blank_menu_opens_solid_settings() { + let mut h = harness(); + let p = rect(&h, "timeline.layerMarquee").center(); + right_click(&mut h, p); + h.get_by_label("New ⏵").click(); + h.run_steps(3); + h.get_by_label("Solid…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); +} + +#[test] +fn project_item_and_timeline_layer_keep_their_own_context_menus() { + let mut h = harness(); + let comp = h.state().session.active_comp_id().unwrap(); + let p = rect(&h, &format!("project.item.{}.name", comp.0)).center(); + right_click(&mut h, p); + assert!(h.query_by_label("Open Composition").is_some()); + assert!(h.query_by_label("New Folder").is_none()); + egui::Popup::close_all(&h.ctx); + h.state_mut().session.execute("layer.newNull", json!({})).unwrap(); + h.run_steps(3); + let id = h.state().session.active_comp().unwrap().layers[0].id; + let r = rect(&h, &format!("timeline.layer.{}.row", id.0)); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + assert!(h.query_by_label("Duplicate").is_some()); + assert!(h.query_by_label("Composition Settings…").is_none()); +} + +#[test] +fn project_blank_import_invokes_the_file_picker() { + let mut h = harness(); + let called = std::rc::Rc::new(std::cell::Cell::new(false)); + let picked = called.clone(); + h.state_mut().hooks.pick_files = Some(Box::new(move |_| { + picked.set(true); + Vec::new() + })); + let p = rect(&h, "project.empty").center(); + right_click(&mut h, p); + h.get_by_label("Import File…").click(); + h.run_steps(3); + assert!(called.get()); + assert!(h.query_by_label("Import File…").is_none()); +} + +#[test] +fn timeline_blank_menu_opens_composition_settings() { + let mut h = harness(); + let p = rect(&h, "timeline.layerMarquee").center(); + right_click(&mut h, p); + h.get_by_label("Composition Settings…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); +}