From 7304f4c67217316a91cc83cf4b431ff144479b5f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:54:50 +0200 Subject: [PATCH] =?UTF-8?q?Project=20panel:=20Composition=20Settings?= =?UTF-8?q?=E2=80=A6=20in=20a=20comp's=20right-click=20menu=20(fixes=20#10?= =?UTF-8?q?58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First in the menu, as in After Effects, and for the right-clicked comp rather than the one in the viewer: the dialog keeps the comp it edits and OK passes it to comp.settings. comp.settings with only {"comp": id} opens the dialog on that comp. --- crates/ui-egui/src/menus.rs | 10 +++++ crates/ui-egui/src/panels/dialogs.rs | 14 ++++++- crates/ui-egui/src/panels/project.rs | 5 +++ crates/ui-egui/tests/ui_project_rows.rs | 52 +++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31..1379e654 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -271,6 +271,13 @@ fn no_params(p: &Value) -> bool { p.as_object().is_none_or(|m| m.is_empty()) } +/// `{"comp": id}` and nothing else: Composition Settings opens on that comp instead of +/// applying no settings to it. +fn only_comp(p: &Value) -> Option { + let m = p.as_object().filter(|m| m.len() == 1)?; + m.get("comp")?.as_u64().map(effectcraft_engine::project::ItemId) +} + /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { // Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of @@ -647,6 +654,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V "app.home" => app.ui.start_screen = !app.ui.start_screen, "app.newComp" | "comp.new" if no_params(¶ms) => crate::panels::dialogs::open_new_comp(app), "app.compSettings" | "comp.settings" if no_params(¶ms) => crate::panels::dialogs::open_comp_settings(app)?, + "app.compSettings" | "comp.settings" if only_comp(¶ms).is_some() => { + crate::panels::dialogs::open_comp_settings_for(app, only_comp(¶ms).ok_or("missing `comp`")?)? + } "app.solidSettings" | "layer.newSolid" if no_params(¶ms) => crate::panels::dialogs::open_new_solid(app)?, "keys.velocity" if no_params(¶ms) => crate::panels::key_dialogs::open_velocity(app)?, "keys.interpolation" if no_params(¶ms) => crate::panels::key_dialogs::open_interpolation(app)?, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e95a667e..ba82a2a5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -20,6 +20,9 @@ pub struct DialogState { /// from them, as in After Effects). pub last_new_comp: Option, pub editing_existing: bool, + /// The composition Composition Settings edits (the Project panel's row menu can pick one + /// the viewer doesn't show). + pub editing_comp: Option, pub solid_name: String, pub solid_color: [f32; 3], pub solid_size: [u32; 2], @@ -75,6 +78,11 @@ pub fn open_new_comp(app: &mut EffectcraftApp) { pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> { let cid = app.session.active_comp_id().ok_or("no composition is open")?; + open_comp_settings_for(app, cid) +} + +/// Composition Settings on `cid`, open in the viewer or not. +pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<(), String> { let c = app.session.project.comp(cid).ok_or("no composition")?; app.dialog_state.comp = CompDraft { name: app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(), @@ -95,6 +103,7 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> { ..Default::default() }; app.dialog_state.editing_existing = true; + app.dialog_state.editing_comp = Some(cid); app.dialog = Some(Dialog::CompSettings); Ok(()) } @@ -362,7 +371,10 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis app.dialog_state.comp = d; match result { Some(serde_json::Value::Null) => app.dialog = None, - Some(params) => { + Some(mut params) => { + if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) { + m.insert("comp".into(), json!(cid.0)); + } let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; if !existing && r.is_ok() { app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone()); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index e18f76bc..a608c175 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -782,6 +782,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let footage = matches!(it.kind, ItemKind::Footage(_)); crate::widgets::context_menu(&resp, |ui| { + // First on a comp, as in After Effects; for this comp, not the viewer's (#1058). + if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Composition Settings…")).clicked() { + actions.push(("comp.settings".into(), json!({"comp": id.0}))); + ui.close(); + } if ui.button(crate::i18n::tr("Delete")).clicked() { actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)}))); ui.close(); diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 7d0ebf66..e43a9ce9 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -214,3 +214,55 @@ fn a_short_project_panel_draws() { assert!(h.state().auto.find("project.empty").is_some(), "the Project panel drew at {height} points"); } } + +/// #1058: a comp's row menu offers Composition Settings…, as After Effects' does, for the comp +/// right-clicked even when the viewer shows another one; OK applies to that comp. Footage has none. +#[test] +fn comp_context_menu_opens_that_comps_settings() { + let (mut h, _, main, _) = harness(); + let s = &mut h.state_mut().session; + let side = ItemId(s.execute("comp.new", json!({"name": "Side", "width": 640, "height": 360, "duration": 2})).unwrap()["comp"].as_u64().unwrap()); + s.execute("comp.open", json!({"comp": main.0})).unwrap(); + let footage = Footage { kind: FootageKind::Video, path: "clip.mp4".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + h.run_steps(2); + assert_eq!(h.state().session.active_comp_id(), Some(main)); + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + }; + let at = center(&h, &format!("project.item.{}.name", clip.0)); + right_click(&mut h, at); + assert!(h.query_by_label("Rename").is_some(), "the footage menu is open"); + assert!(h.query_by_label_contains("Composition Settings").is_none(), "footage isn't a comp"); + h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let at = center(&h, &format!("project.item.{}.name", side.0)); + right_click(&mut h, at); + let entry = h.query_by_label_contains("Composition Settings").expect("in the comp's menu").rect(); + click_at(&mut h, entry.center()); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp"); + // A new name, then OK: Side's own size goes back to Side under it. Main's values in the + // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. + click(&mut h, "dialog.comp.name"); + h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.input_mut().events.push(Event::Text("Side 2".into())); + h.run_steps(2); + let undo = h.state().session.history.undo.len(); + h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "OK closed the dialog"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "OK applied the settings"); + let size = |h: &Harness<'_, EffectcraftApp>, c: ItemId| { + let s = &h.state().session; + (s.project.item(c).unwrap().name.clone(), s.project.comp(c).unwrap().width, s.project.comp(c).unwrap().height) + }; + assert_eq!(size(&h, side), ("Side 2".into(), 640, 360), "the dialog showed and set the right-clicked comp"); + assert_eq!(size(&h, main), ("Main".into(), 320, 180), "the open comp is untouched"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3654f61b..4e6d04e1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -23,7 +23,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) | | Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) | | Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels, not matched | -| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`; #227, #290, #407 fixed | +| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`, `ui_project_rows`; #227, #290, #407 fixed; a comp's Project row menu has Composition Settings… (#1058) | | Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; adding shortcuts fails on Linux (#467); requests for more (#428, #336); no shortcuts for scripts | | Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); multi-file drops (#552, #351); crash dragging over the import dialog (#511) | | Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`, `ui_font_search`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); the font menus' variable-font filter (26.3) landed (#741) |