Files
effectcraft/crates/ui-egui/tests/ui_blank_context_menus.rs
T
bflatasticandClaude Opus 5.5 79bf5a6b59 Timeline: property names have the property menu (#407)
Right-clicking a property's name in the Timeline did nothing, so
Position's dimensions could be separated only from the Graph Editor.

As in After Effects, it now opens the property's menu: Reset, Edit
Value…, Separate Dimensions (Position and, once separated, X / Y / Z
Position, checked), Add / Remove Expression and Add Property to
Essential Graphics. Only Position separates, as in After Effects
(Scale stays one property).

`prop.reset` without `default` used to leave the value as it was: it
now gives the property its own default (an effect parameter's, or a
Transform property's, shared with Layer ▸ Transform ▸ Reset), which
also makes the Properties panel's Reset reset the values. `prop.set
{layer, prop}` without `value` opens the value dialog, like
`keys.set`.

Tests: timeline_property_names_have_the_property_menu
(ui_blank_context_menus), reset_gives_a_property_its_default (engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:52:56 +07:00

198 lines
8.6 KiB
Rust

//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names.
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));
}
/// #407: right-clicking a property's name in the Timeline opens After Effects' property menu:
/// Reset, Edit Value…, Separate Dimensions (Position only, checked once separated), Add or
/// Remove Expression and Add Property to Essential Graphics.
#[test]
fn timeline_property_names_have_the_property_menu() {
let mut h = harness();
let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/rotation", "value": 30})).unwrap();
let props =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone();
let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid;
h.state_mut().ui.timeline.open_layers.insert(layer);
let transform = props(&h).sub("transform").unwrap().uid;
h.state_mut().ui.timeline.open_groups.insert(transform);
h.run_steps(3);
let menu = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| {
let p = rect(h, &format!("timeline.prop.{uid}.name")).center();
right_click(h, p);
};
let choose = |h: &mut Harness<'_, EffectcraftApp>, uid: u64, entry: &str| {
let p = rect(h, &format!("timeline.prop.{uid}.menu.{entry}")).center();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
// Reset: Rotation's default.
let rotation = uid(&h, "transform/rotation");
menu(&mut h, rotation);
assert!(h.query_by_label("Edit Value…").is_some());
assert!(h.query_by_label("Separate Dimensions").is_none(), "only Position separates");
choose(&mut h, rotation, "reset");
assert_eq!(props(&h).prop("transform/rotation").unwrap().value.as_f64(), 0.0);
// Separate Dimensions on Position; X Position's menu has it too, and joins them again.
let position = uid(&h, "transform/position");
menu(&mut h, position);
choose(&mut h, position, "separateDimensions");
assert!(props(&h).prop("transform/positionX").is_some(), "separated");
let x = uid(&h, "transform/positionX");
menu(&mut h, x);
choose(&mut h, x, "separateDimensions");
assert!(props(&h).prop("transform/positionX").is_none(), "joined");
// Add Expression, then Remove Expression.
let opacity = uid(&h, "transform/opacity");
menu(&mut h, opacity);
choose(&mut h, opacity, "addExpression");
assert_eq!(props(&h).prop("transform/opacity").unwrap().expr.as_ref().map(|e| e.text.clone()).as_deref(), Some("transform.opacity"));
menu(&mut h, opacity);
choose(&mut h, opacity, "removeExpression");
assert!(props(&h).prop("transform/opacity").unwrap().expr.is_none());
// Add Property to Essential Graphics.
menu(&mut h, opacity);
choose(&mut h, opacity, "essentialGraphics");
let comp = h.state().session.active_comp_id().unwrap();
assert_eq!(h.state().session.project.comp(comp).unwrap().essential.as_ref().map_or(0, |e| e.controls.len()), 1);
// Edit Value… opens the value dialog.
let scale = uid(&h, "transform/scale");
menu(&mut h, scale);
choose(&mut h, scale, "editValue");
assert_eq!(h.state().dialog, Some(Dialog::Form));
}