Project panel: Composition Settings… in a comp's right-click menu (fixes #1058)

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.
This commit is contained in:
Marco Arnulfo committed 2026-10-12 18:54:56 +02:00
1 parent 470ea7a3af
commit 7304f4c672
5 files changed
+81 -2

No files matched your search

+10
View File
@@ -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<effectcraft_engine::project::ItemId> {
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<Value, String> {
// 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(&params) => crate::panels::dialogs::open_new_comp(app),
"app.compSettings" | "comp.settings" if no_params(&params) => crate::panels::dialogs::open_comp_settings(app)?,
"app.compSettings" | "comp.settings" if only_comp(&params).is_some() => {
crate::panels::dialogs::open_comp_settings_for(app, only_comp(&params).ok_or("missing `comp`")?)?
}
"app.solidSettings" | "layer.newSolid" if no_params(&params) => crate::panels::dialogs::open_new_solid(app)?,
"keys.velocity" if no_params(&params) => crate::panels::key_dialogs::open_velocity(app)?,
"keys.interpolation" if no_params(&params) => crate::panels::key_dialogs::open_interpolation(app)?,
+13 -1
View File
@@ -20,6 +20,9 @@ pub struct DialogState {
/// from them, as in After Effects).
pub last_new_comp: Option<CompDraft>,
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<ItemId>,
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());
+5
View File
@@ -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();
+52
View File
@@ -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");
}
+1 -1
View File
@@ -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) |