//! The menu bar from the keyboard, and the app's shortcuts waiting while a menu is open (#279) //! (egui_kittest, UI logic only). use effectcraft_engine::Session; use effectcraft_ui_egui::EffectcraftApp; use egui::{Event, Key, Modifiers, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; use serde_json::json; fn harness() -> Harness<'static, EffectcraftApp> { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); h } fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) } fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.input_mut().events.push(Event::PointerMoved(p)); h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); h.step(); h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); h.run_steps(2); } /// Press and release `key` (one frame each), then let the UI settle. fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.step(); h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(3); } /// The label of the widget with keyboard focus (the highlighted menu entry). fn focused(h: &Harness<'_, EffectcraftApp>) -> String { h.query_by(|n| n.is_focused()).and_then(|n| n.accesskit_node().label()).unwrap_or_default().trim().to_string() } fn shown(h: &Harness<'_, EffectcraftApp>, label: &str) -> bool { h.query_by_label_contains(label).is_some() } /// Space (or any shortcut) while a menu is open goes to the menu, not the app: it started the /// preview behind the open menu. #[test] fn shortcuts_wait_while_a_menu_is_open() { let mut h = harness(); let edit = rect(&h, "menu.Edit"); click_at(&mut h, edit.center()); assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu is open"); key(&mut h, Key::Space); assert!(!h.state().playback.playing, "Space didn't start the preview"); // With the menu closed, Space previews again. key(&mut h, Key::Escape); assert!(!shown(&h, "Keyboard Shortcuts"), "Escape closed the menu"); key(&mut h, Key::Space); assert!(h.state().playback.playing, "Space previews with the menus closed"); } /// Alt focuses the menu bar; Down opens the menu, Up / Down move the highlight, Right opens a /// submenu and Left closes it, Right on an entry opens the next menu, Escape closes them. #[test] fn arrow_keys_drive_the_menu_bar() { if cfg!(target_os = "macos") { // (macOS menus are the system's.) return; } let mut h = harness(); h.input_mut().events.push(Event::ModifiersChanged(Modifiers::ALT)); h.step(); h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); h.run_steps(2); assert_eq!(focused(&h), "File", "Alt focused the menu bar"); key(&mut h, Key::ArrowDown); assert!(shown(&h, "Open Project"), "Down opened File"); assert!(focused(&h).starts_with("New"), "its first entry is highlighted: {:?}", focused(&h)); key(&mut h, Key::ArrowRight); assert!(focused(&h).contains("New Project"), "Right opened the New submenu: {:?}", focused(&h)); key(&mut h, Key::ArrowDown); assert!(focused(&h).contains("New Project from Template"), "Down moved down the submenu: {:?}", focused(&h)); key(&mut h, Key::ArrowLeft); assert!(!shown(&h, "New Project from Template"), "Left closed the submenu"); assert!(focused(&h).starts_with("New"), "and went back to its entry: {:?}", focused(&h)); key(&mut h, Key::ArrowDown); assert!(focused(&h).contains("Open Project"), "{:?}", focused(&h)); key(&mut h, Key::ArrowRight); assert!(!shown(&h, "Open Project"), "Right on an entry left File"); assert!(shown(&h, "Keyboard Shortcuts"), "for Edit"); key(&mut h, Key::ArrowLeft); assert!(shown(&h, "Open Project"), "Left went back to File"); key(&mut h, Key::Escape); assert!(!shown(&h, "Open Project"), "Escape closed the menus"); assert!(!h.state().playback.playing, "no key reached the app"); } /// Up in a menu opened with the pointer highlights its last entry (wrapping around); Enter on a /// submenu entry opens it with its first entry highlighted, and Enter chooses an entry. #[test] fn enter_chooses_the_highlighted_entry() { let mut h = harness(); let edit = rect(&h, "menu.Edit"); click_at(&mut h, edit.center()); key(&mut h, Key::ArrowUp); if cfg!(target_os = "macos") { assert!(focused(&h).starts_with("Keyboard Shortcuts"), "Up went to the last entry: {:?}", focused(&h)); key(&mut h, Key::Enter); assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "Enter chose it"); } else { assert!(focused(&h).starts_with("Preferences"), "Up went to the last entry: {:?}", focused(&h)); key(&mut h, Key::Enter); assert!(focused(&h).starts_with("General"), "Enter opened the submenu: {:?}", focused(&h)); key(&mut h, Key::Enter); assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Settings), "Enter chose General..."); } assert!(!shown(&h, "Quick Apply"), "and closed the menu"); } #[test] fn relink_menu_uses_engine_availability_and_folder_picker_cancel_is_a_noop() { use std::cell::Cell; use std::rc::Rc; let mut app = EffectcraftApp::new(Session::default()); let menu = effectcraft_ui_egui::menus::menu_items(&app); let entry = menu.iter().find(|e| e.id == "file.relinkFootage").unwrap(); assert_eq!(entry.path, ["File", "Dependencies"]); assert_eq!(entry.enabled, app.session.is_enabled("file.relinkFootage")); assert_eq!(entry.enabled, !cfg!(target_arch = "wasm32")); let picked = Rc::new(Cell::new(false)); let flag = picked.clone(); app.hooks.pick_folder = Some(Box::new(move || { flag.set(true); None })); let revision = app.session.revision; let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.relinkFootage", json!({})).unwrap(); assert!(picked.get()); assert!(result.is_null()); assert_eq!(app.session.revision, revision); assert!(app.session.history.undo.is_empty()); } #[test] fn relink_is_reachable_through_the_dependencies_menu() { use std::cell::Cell; use std::rc::Rc; let mut h = harness(); let picked = Rc::new(Cell::new(false)); let flag = picked.clone(); h.state_mut().hooks.pick_folder = Some(Box::new(move || { flag.set(true); None })); let revision = h.state().session.revision; let file = rect(&h, "menu.File"); click_at(&mut h, file.center()); let dependencies = h.query_by_label_contains("Dependencies").unwrap().rect(); h.input_mut().events.push(Event::PointerMoved(dependencies.center())); h.run_steps(8); let relink = h.query_by_label_contains("Relink Missing Footage").unwrap().rect(); if let Some(path) = std::env::var_os("EFFECTCRAFT_RELINK_MENU_SNAPSHOT") { h.render().unwrap().save(path).unwrap(); } click_at(&mut h, relink.center()); assert!(picked.get()); assert_eq!(h.state().session.revision, revision); } #[test] fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { use effectcraft_engine::project::{ItemId, ItemKind}; use effectcraft_engine::render::RenderOpts; use effectcraft_engine::time::Tick; let dir = std::env::temp_dir().join(format!("ec-ui-relink-{}", std::process::id())); std::fs::create_dir_all(dir.join("old")).unwrap(); std::fs::create_dir_all(dir.join("new/deep")).unwrap(); let old = dir.join("old/plate.png"); image::RgbaImage::from_pixel(32, 32, image::Rgba([230, 40, 80, 255])).save(&old).unwrap(); let mut s = effectcraft_host::session(); let item = s.execute("file.import", json!({"paths": [old], "sequence": false})).unwrap()["items"][0].as_u64().unwrap(); s.execute("comp.new", json!({"name": "Relink", "width": 32, "height": 32, "duration": 1})).unwrap(); s.execute("layer.addItem", json!({"item": item})).unwrap(); let cid = s.active_comp_id().unwrap(); let expected = s.render(cid, Tick::ZERO, RenderOpts::default()); let moved = dir.join("new/deep/plate.png"); std::fs::rename(&old, &moved).unwrap(); s.execute("footage.check", json!({"wait": true})).unwrap(); let ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!("footage") }; assert!(f.missing); let missing_content = effectcraft_ui_egui::frames::comp_content(&s.project, cid); let missing = s.render(cid, Tick::ZERO, RenderOpts::default()); assert_ne!(missing.data, expected.data); let r = s.execute("file.relinkFootage", json!({"folder": dir.join("new"), "wait": true})).unwrap(); assert_eq!(r["relinked"], 1); assert_ne!(effectcraft_ui_egui::frames::comp_content(&s.project, cid), missing_content); assert_eq!(s.render(cid, Tick::ZERO, RenderOpts::default()).data, expected.data); std::fs::remove_dir_all(dir).unwrap(); } /// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and /// Avid), which the Media Browser lists and the importer reads. #[test] fn import_dialog_offers_mxf() { use std::cell::RefCell; use std::rc::Rc; let mut app = EffectcraftApp::new(Session::default()); let offered = Rc::new(RefCell::new(Vec::::new())); let seen = offered.clone(); app.hooks.pick_files = Some(Box::new(move |exts: &[&str]| { *seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect(); Vec::new() })); let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap(); assert!(result.is_null(), "cancelled: nothing imported"); let offered = offered.borrow(); assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}"); assert!(offered.iter().any(|e| e == "mov")); }