Merge pull request #210 from jonassneu/fix/blank-panel-context-menus

G2: add context menus to blank Project and Timeline areas
This commit is contained in:
BFlat 2026-10-08 22:11:11 +07:00 committed by GitHub
commit d60cb71e29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 179 additions and 1 deletions

View File

@ -327,7 +327,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// a click deselects, a drag draws a selection box, a double-click imports, as in After
// Effects (File ▸ Import ▸ File...).
let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click_and_drag());
app.auto.add("project.empty", list, "Double-click to import files; drag to select items");
app.auto.add("project.empty", list, "Double-click to import files; drag to select items; 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 {
@ -721,6 +721,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();
}
}
});
// Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection.
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if empty.clicked() && !adding {

View File

@ -2352,6 +2352,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": []})));
}

View File

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