diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 26a7b66f..b5d071a2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -300,6 +300,17 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if id == "layer.newText" && no_params(¶ms) { return run_engine(app, ctx, id, json!({"text": "", "edit": true})); } + // A new folder (File ▸ New, its shortcut, the Project panel's button and context menu) opens + // with its name ready to type, as in After Effects (#1658). The command selects it. + // `engine.execute` keeps the headless default. + if id == "project.newFolder" && no_params(¶ms) { + let r = run_engine(app, ctx, id, params); + if r.is_ok() { + app.show_panel(PanelKind::Project); + crate::panels::project::begin_rename(app, ctx); + } + return r; + } if no_params(¶ms) && app.ui.focused == PanelKind::Project { match id { "edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})), diff --git a/crates/ui-egui/tests/ui_project_new_folder.rs b/crates/ui-egui/tests/ui_project_new_folder.rs new file mode 100644 index 00000000..0ee75c7f --- /dev/null +++ b/crates/ui-egui/tests/ui_project_new_folder.rs @@ -0,0 +1,158 @@ +//! #1658: a folder made from the interface (the Project panel's New Folder button and context +//! menu, File ▸ New ▸ New Folder and its shortcut) opens with its name ready to type, as in After +//! Effects: Enter keeps the typed name, Escape the default one. `engine.execute` (agents, MCP, +//! scripts) keeps the headless default and only creates the folder. + +use effectcraft_engine::Session; +use effectcraft_engine::project::ItemId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::control::{self, ControlRequest}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +/// A comp and an open folder holding a solid, nothing selected. +fn harness(comps: usize) -> (Harness<'static, EffectcraftApp>, ItemId) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + for i in 0..comps { + s.execute("comp.new", json!({"name": format!("Comp {i:03}"), "width": 64, "height": 64, "duration": 1})).unwrap(); + } + let folder = ItemId(s.execute("project.newFolder", json!({"name": "Bin"})).unwrap()["item"].as_u64().unwrap()); + s.execute("project.select", json!({"items": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.project_open_folders.insert(folder.0); + h.run_steps(3); + (h, folder) +} + +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]), egui::vec2(e.rect[2], e.rect[3])) +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +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.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) { + assert!(h.ctx.egui_wants_keyboard_input(), "the name field has the keyboard"); + h.input_mut().events.push(Event::Text(text.into())); + h.step(); +} + +/// The folders not in `before`. +fn new_folders(h: &Harness<'_, EffectcraftApp>, before: &[ItemId]) -> Vec { + h.state().session.project.items.values().filter(|i| i.is_folder() && !before.contains(&i.id)).map(|i| i.id).collect() +} + +fn folders(h: &Harness<'_, EffectcraftApp>) -> Vec { + new_folders(h, &[]) +} + +fn name(h: &Harness<'_, EffectcraftApp>, id: ItemId) -> Option { + h.state().session.project.item(id).map(|i| i.name.clone()) +} + +fn renaming(h: &Harness<'_, EffectcraftApp>, id: ItemId) -> bool { + let edit = format!("project.item.{}.nameEdit", id.0); + h.state().auto.elements.iter().any(|e| e.id == edit) +} + +/// The panel's button, with a folder selected: the new folder opens its name, Enter keeps the +/// typed one, and undo takes back the rename, then the folder (two steps, like any rename). +#[test] +fn the_new_folder_button_opens_the_name_and_enter_keeps_it() { + let (mut h, bin) = harness(0); + let p = rect(&h, &format!("project.item.{}.name", bin.0)).center(); + click_at(&mut h, p, PointerButton::Primary); + assert_eq!(h.state().session.state.project_selection, [bin]); + let before = folders(&h); + let p = rect(&h, "project.newFolder").center(); + click_at(&mut h, p, PointerButton::Primary); + let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") }; + assert_eq!(h.state().session.state.project_selection, [folder], "the new folder is selected"); + assert!(renaming(&h, folder), "its name is open for editing"); + type_text(&mut h, "Shots"); + key(&mut h, Key::Enter); + assert_eq!(name(&h, folder).as_deref(), Some("Shots"), "Enter keeps the typed name"); + assert!(!renaming(&h, folder), "Enter closes the field"); + assert!(!h.ctx.egui_wants_keyboard_input(), "and gives the keyboard back"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(name(&h, folder).as_deref(), Some("Untitled Folder"), "the first undo takes back the rename"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(name(&h, folder), None, "the second, the folder"); +} + +/// Escape keeps the default name: the folder stays, with one undo step. +#[test] +fn escape_keeps_the_default_name() { + let (mut h, _) = harness(0); + let before = folders(&h); + let p = rect(&h, "project.newFolder").center(); + click_at(&mut h, p, PointerButton::Primary); + let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") }; + type_text(&mut h, "Never mind"); + key(&mut h, Key::Escape); + assert_eq!(name(&h, folder).as_deref(), Some("Untitled Folder")); + assert!(!renaming(&h, folder)); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(name(&h, folder), None, "one undo removes the folder"); +} + +/// The context menu of the list's empty area. +#[test] +fn the_context_menu_opens_the_name() { + let (mut h, _) = harness(0); + let list = rect(&h, "project.empty"); + let before = folders(&h); + click_at(&mut h, pos2(list.center().x, list.max.y - 4.0), PointerButton::Secondary); + let p = rect(&h, "project.context.newFolder").center(); + click_at(&mut h, p, PointerButton::Primary); + let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") }; + assert!(renaming(&h, folder), "the context menu's New Folder opens the name"); +} + +/// File ▸ New ▸ New Folder (the menu dispatcher, which its shortcut goes through too): a folder +/// sorted below the visible rows scrolls into view with its name open. +#[test] +fn the_file_menu_opens_the_name_in_view() { + let (mut h, _) = harness(80); + let list = rect(&h, "project.empty"); + let before = folders(&h); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "project.newFolder", json!({})).unwrap(); + h.run_steps(3); + let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") }; + assert!(renaming(&h, folder), "File ▸ New ▸ New Folder opens the name"); + let field = rect(&h, &format!("project.item.{}.nameEdit", folder.0)); + assert!(list.contains_rect(field), "the field is in view: {field:?} in {list:?}"); + type_text(&mut h, "Renders"); + key(&mut h, Key::Enter); + assert_eq!(name(&h, folder).as_deref(), Some("Renders")); +} + +/// Agents and scripts: `engine.execute` only creates the folder. +#[test] +fn engine_execute_only_creates_the_folder() { + let (mut h, _) = harness(0); + let before = folders(&h); + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new("engine.execute", json!({"command": "project.newFolder"})); + let _ = control::handle(h.state_mut(), &ctx, &req); + h.run_steps(3); + let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") }; + assert!(!renaming(&h, folder), "no name field for an agent's folder"); + assert!(!h.ctx.egui_wants_keyboard_input()); +}