From 14b6b9c2423021a0452ab9b70f700b20fbc87186 Mon Sep 17 00:00:00 2001 From: Anton Iliashenko <183588867+aniliashenko@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:16:52 +0200 Subject: [PATCH] Composition Settings: save and delete your own presets As in After Effects, the Preset menu in Composition Settings / New Composition gets a Save button (asks for the name in a Choose Name window; an existing name of yours is replaced) and a Delete button for the selected preset of yours. Your presets keep frame size, pixel aspect and frame rate, are listed above the built-in ones and live in the settings (`compPresets`), so they survive restarts and Reset All. Commands: `comp.savePreset`, `comp.deletePreset`, `comp.presets`. A hand-edited settings file loses only its invalid presets. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/comp_more.rs | 75 +++++++++++- crates/engine/src/prefs.rs | 46 +++++++- crates/engine/src/tests_settings.rs | 44 +++++++ crates/ui-egui/src/panels/comp_settings.rs | 127 +++++++++++++++++++-- crates/ui-egui/src/panels/dialogs.rs | 21 +++- crates/ui-egui/tests/ui_comp_presets.rs | 85 ++++++++++++++ 6 files changed, 380 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/tests/ui_comp_presets.rs diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 2c4ca409b..154a0a814 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -5,8 +5,9 @@ use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; -use crate::{EngineError, Result, Session, cmd}; +use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; +use crate::prefs::CompPreset; +use crate::{EngineError, Result, Session, cmd, query}; fn has_roi(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; @@ -143,6 +144,65 @@ fn responsive_time(s: &mut Session, p: &Value) -> Result { Ok(json!({"start": start.seconds(), "end": end.seconds()})) } +// ---------------------------------------------------------------- composition presets + +/// `comp.savePreset`: remember a frame size, pixel aspect and frame rate under a name in the +/// settings (shown in Composition Settings ▸ Preset). A preset with the same name is replaced. +fn save_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "comp.savePreset"; + let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?; + let num = |k: &str| f_p(p, k).ok_or_else(|| bad(C, format!("missing `{k}`"))); + let dim = |k: &str| { + num(k).and_then(|v| { + if v.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&v) { Ok(v as u32) } else { Err(bad(C, format!("`{k}` must be a whole number"))) } + }) + }; + let preset = + CompPreset { name: name.to_string(), width: dim("width")?, height: dim("height")?, pixel_aspect: num("pixelAspect")?, frame_rate: num("frameRate")? }; + if !preset.is_valid() { + return Err(bad( + C, + format!( + "not a valid preset: width and height 4–30000 px, pixel aspect 0.01–100, frame rate 1–999, a name of at most {} characters", + CompPreset::MAX_NAME + ), + )); + } + let presets = &mut s.prefs.comp_presets; + let replaced = match presets.iter_mut().find(|q| q.name == preset.name) { + Some(q) => { + *q = preset; + true + } + None => { + presets.push(preset); + false + } + }; + s.save_prefs(); + s.prefs_revision += 1; + Ok(json!({"name": name, "replaced": replaced})) +} + +/// `comp.deletePreset`: forget a saved composition preset. +fn delete_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "comp.deletePreset"; + let name = str_p(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let before = s.prefs.comp_presets.len(); + s.prefs.comp_presets.retain(|q| q.name != name); + if s.prefs.comp_presets.len() == before { + return Err(bad(C, format!("no saved preset named `{name}`"))); + } + s.save_prefs(); + s.prefs_revision += 1; + Ok(Value::Null) +} + +/// `comp.presets`: the saved composition presets. +fn list_presets(s: &mut Session, _: &Value) -> Result { + serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string())) +} + pub fn specs() -> Vec { vec![ cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi), @@ -156,6 +216,17 @@ pub fn specs() -> Vec { has_comp, save_frame ), + cmd!( + "comp.savePreset", + "Save Composition Preset", + [], + None, + "{name, width, height, pixelAspect, frameRate} (replaces a saved preset with the same name)", + always, + save_preset + ), + cmd!("comp.deletePreset", "Delete Composition Preset", [], None, "{name}", always, delete_preset), + query!("comp.presets", "Composition Presets", "{}", list_presets), cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time), ] } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8f6af9c2c..250806f72 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -335,6 +335,42 @@ pub struct LabelPref { pub color: String, } +/// A Composition Settings preset the user saved (Composition Settings ▸ Preset ▸ Save): the +/// frame size, pixel aspect and frame rate, as in After Effects' own presets. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompPreset { + pub name: String, + pub width: u32, + pub height: u32, + pub pixel_aspect: f64, + pub frame_rate: f64, +} + +impl CompPreset { + /// Longest preset name kept. + pub const MAX_NAME: usize = 128; + + /// Values a composition accepts (a hand-edited settings file can hold anything). + pub fn is_valid(&self) -> bool { + let size = 4..=30000; + !self.name.trim().is_empty() + && self.name.chars().count() <= Self::MAX_NAME + && size.contains(&self.width) + && size.contains(&self.height) + && self.pixel_aspect.is_finite() + && (0.01..=100.0).contains(&self.pixel_aspect) + && self.frame_rate.is_finite() + && (1.0..=999.0).contains(&self.frame_rate) + } +} + +/// Saved composition presets, skipping entries that don't parse instead of losing the list. +fn lenient_comp_presets<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = Vec::::deserialize(d)?; + Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect()) +} + fn default_labels() -> Vec { Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect() } @@ -393,6 +429,9 @@ pub struct Prefs { pub preview: crate::preview::PreviewSettings, /// View ▸ Simulate Output ▸ My Custom RGB. pub custom_rgb: crate::viewer::CustomRgb, + /// Composition Settings presets the user saved, in the order saved. + #[serde(deserialize_with = "lenient_comp_presets")] + pub comp_presets: Vec, /// Top-level keys this version doesn't know (kept for newer versions). #[serde(flatten)] pub extra: Extra, @@ -428,6 +467,7 @@ impl Default for Prefs { recent_fonts: vec![], preview: crate::preview::PreviewSettings::default(), custom_rgb: crate::viewer::CustomRgb::default(), + comp_presets: vec![], extra: Extra::new(), } } @@ -553,6 +593,7 @@ impl Prefs { /// Clamp values into their valid ranges and fill missing labels. pub fn normalize(&mut self) { self.version = PREFS_VERSION; + self.comp_presets.retain(CompPreset::is_valid); if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) { self.startup.window_graphics = Startup::default().window_graphics; } @@ -642,8 +683,8 @@ impl Prefs { Ok(()) } - /// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is - /// kept. + /// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and + /// the saved composition presets are kept. pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> { let d = Prefs::default(); let Some(page) = page else { @@ -653,6 +694,7 @@ impl Prefs { recent_presets: std::mem::take(&mut self.recent_presets), recent_fonts: std::mem::take(&mut self.recent_fonts), preview: std::mem::take(&mut self.preview), + comp_presets: std::mem::take(&mut self.comp_presets), ..d }; return Ok(()); diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 1a445205b..ae8e018de 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -214,6 +214,50 @@ fn prefs_commands_get_set_reset_and_persist() { ); } +/// Composition Settings presets the user saves live in the settings: they persist, a save with +/// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited +/// settings file) are dropped on load. +#[test] +fn composition_presets_save_replace_delete_and_persist() { + let store = Arc::new(MemoryConfig::default()); + let mut s = Session { config: Some(store.clone()), ..Default::default() }; + let p = |name: &str, w: u32, fps: f64| json!({"name": name, "width": w, "height": 1440, "pixelAspect": 1.0, "frameRate": fps}); + assert_eq!(s.execute_checked("comp.savePreset", p(" Edit 1440p 60 ", 2560, 60.0)).unwrap(), json!({"name": "Edit 1440p 60", "replaced": false})); + s.execute_checked("comp.savePreset", p("Vertical", 1440, 30.0)).unwrap(); + assert_eq!(s.execute("comp.savePreset", p("Vertical", 1080, 30.0)).unwrap()["replaced"], json!(true)); + let list = s.execute("comp.presets", json!({})).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + assert_eq!(list[0]["name"], "Edit 1440p 60"); + assert_eq!((list[0]["width"].clone(), list[0]["frameRate"].clone()), (json!(2560), json!(60.0))); + assert_eq!(list[1]["width"], json!(1080), "replaced in place"); + for bad in [ + p("", 1920, 30.0), + p("Tiny", 2, 30.0), + p("Fast", 1920, 5000.0), + json!({"name": "x", "width": 1920.5, "height": 1080, "pixelAspect": 1.0, "frameRate": 30}), + json!({"name": "x"}), + ] { + assert!(s.execute("comp.savePreset", bad.clone()).is_err(), "{bad}"); + } + // Persisted, and kept by Reset All. + let mut s2 = Session { config: Some(store.clone()), ..Default::default() }; + s2.load_settings(); + assert_eq!(s2.prefs.comp_presets, s.prefs.comp_presets); + s2.execute("prefs.reset", json!({})).unwrap(); + assert_eq!(s2.prefs.comp_presets.len(), 2); + s2.execute_checked("comp.deletePreset", json!({"name": "Vertical"})).unwrap(); + assert!(s2.execute("comp.deletePreset", json!({"name": "Vertical"})).is_err(), "already gone"); + let mut s3 = Session { config: Some(store.clone()), ..Default::default() }; + s3.load_settings(); + assert_eq!(s3.prefs.comp_presets.iter().map(|q| q.name.as_str()).collect::>(), ["Edit 1440p 60"]); + // A hand-edited settings file: the broken preset is dropped, the good one kept. + let mut v: serde_json::Value = serde_json::from_str(&store.read(PREFS_FILE).unwrap()).unwrap(); + v["compPresets"].as_array_mut().unwrap().push(json!({"name": "Broken", "width": 0, "height": 1080, "pixelAspect": 1.0, "frameRate": f64::NAN})); + v["compPresets"].as_array_mut().unwrap().push(json!({"name": 7})); + let back = Prefs::from_json(&v.to_string()); + assert_eq!(back.comp_presets.iter().map(|q| q.name.as_str()).collect::>(), ["Edit 1440p 60"]); +} + #[test] fn undo_levels_are_honoured() { let mut s = session_with_comp(); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index ca003c3b8..a72e8ac29 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -3,6 +3,8 @@ //! (`dialog.comp.`). use crate::i18n::{tr, tr_args}; +use crate::icons::Icon; +use effectcraft_engine::prefs::CompPreset; use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode}; use egui::{Color32, vec2}; use serde_json::json; @@ -96,6 +98,8 @@ pub struct CompDraft { /// Timecode text being edited (start, duration). pub start_tc: Option, pub dur_tc: Option, + /// The name being typed for Save Preset (the name row shows while `Some`). + pub preset_name: Option, } impl Default for CompDraft { @@ -121,6 +125,7 @@ impl Default for CompDraft { tab: Tab::Basic, start_tc: None, dur_tc: None, + preset_name: None, } } } @@ -171,8 +176,79 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option, secs: &mut f64, r r } -/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. -pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registry) -> Option { +fn name_focus_id() -> egui::Id { + egui::Id::new("comp-preset-name-focus") +} + +/// Save Preset's Choose Name prompt (drawn in its own modal over the dialog while +/// `d.preset_name` is `Some`): OK or Enter saves the draft's frame size, pixel aspect and frame +/// rate under the name, replacing a saved preset with that name; Cancel or Escape closes it. +pub fn choose_name( + ui: &mut egui::Ui, + d: &mut CompDraft, + t: &Tokens, + auto: &mut Registry, + custom: &[CompPreset], + cmds: &mut Vec<(&'static str, serde_json::Value)>, +) { + let Some(mut name) = d.preset_name.take() else { return }; + let mut keep = true; + let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(ui.available_width())); + auto.add("dialog.comp.presetName", r.rect, "Preset Name"); + if ui.data_mut(|m| m.remove_temp::(name_focus_id())).unwrap_or(false) { + r.request_focus(); + } + let trimmed = name.trim(); + let builtin = PRESETS.iter().any(|p| p.0 == trimmed); + let exists = custom.iter().any(|q| q.name == trimmed); + let ok = !trimmed.is_empty() && !builtin && trimmed.chars().count() <= CompPreset::MAX_NAME; + let note = if builtin { + tr("A built-in preset has this name") + } else if exists { + tr("Replaces your preset with this name") + } else { + "" + }; + ui.add_space(4.0); + ui.label(egui::RichText::new(note).color(Color32::GRAY)); + ui.add_space(8.0); + let mut save = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let mut cancel = ui.input(|i| i.key_pressed(egui::Key::Escape)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = ui.add_enabled(ok, egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); + auto.add("dialog.comp.presetNameSave", b.rect, "OK"); + save |= b.clicked(); + let c = ui.button(tr("Cancel")); + auto.add("dialog.comp.presetNameCancel", c.rect, "Cancel"); + cancel |= c.clicked(); + }); + if ok && save { + cmds.push(("comp.savePreset", json!({"name": trimmed, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps}))); + keep = false; + } + if cancel { + keep = false; + } + if keep { + d.preset_name = Some(name); + } +} + +/// Whether the draft's frame size, pixel aspect and frame rate are those of a preset. +fn matches(d: &CompDraft, w: u32, h: u32, par: f64, fps: f64) -> bool { + w == d.width && h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (fps - d.fps).abs() < 1e-3 +} + +/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` +/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`. +pub fn show( + ui: &mut egui::Ui, + d: &mut CompDraft, + t: &Tokens, + auto: &mut Registry, + custom: &[CompPreset], + cmds: &mut Vec<(&'static str, serde_json::Value)>, +) -> Option { let mut out = None; ui.horizontal(|ui| { ui.label(tr("Composition Name:")); @@ -195,19 +271,48 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr Tab::Basic => { egui::Grid::new("comp-basic").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| { ui.label(tr("Preset:")); - let current = PRESETS - .iter() - .find(|(_, w, h, par, f)| *w == d.width && *h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (f - d.fps).abs() < 1e-3) - .map(|p| p.0) + // The user's own presets win over a built-in one with the same values. + let own = custom.iter().find(|q| matches(d, q.width, q.height, q.pixel_aspect, q.frame_rate)); + let current = own + .map(|q| q.name.as_str()) + .or_else(|| PRESETS.iter().find(|(_, w, h, par, f)| matches(d, *w, *h, *par, *f)).map(|p| p.0)) .unwrap_or("Custom"); - let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| { - for (name, w, h, par, f) in PRESETS { - if ui.selectable_label(*name == current, *name).clicked() { - (d.width, d.height, d.pixel_aspect, d.fps) = (*w, *h, *par, *f); + ui.horizontal(|ui| { + let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| { + // Your own presets first, then the built-in ones. + for q in custom { + if ui.selectable_label(q.name == current, &q.name).clicked() { + (d.width, d.height, d.pixel_aspect, d.fps) = (q.width, q.height, q.pixel_aspect, q.frame_rate); + } + } + if !custom.is_empty() { + ui.separator(); + } + for (name, w, h, par, f) in PRESETS { + if ui.selectable_label(*name == current, *name).clicked() { + (d.width, d.height, d.pixel_aspect, d.fps) = (*w, *h, *par, *f); + } + } + }); + auto.add("dialog.comp.preset", r.response.rect, "Preset"); + let (save_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover()); + let save = crate::widgets::icon_button(ui, save_r, Icon::Plus, d.preset_name.is_some(), t, egui::Id::new("comp-preset-save")) + .on_hover_text(tr("Save Preset…")); + auto.add("dialog.comp.presetSave", save_r, "Save Preset"); + if save.clicked() { + d.preset_name = Some(own.map(|q| q.name.clone()).unwrap_or_default()); + ui.data_mut(|m| m.insert_temp(name_focus_id(), true)); + } + if let Some(q) = own { + let (del_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover()); + let del = crate::widgets::icon_button(ui, del_r, Icon::Trash, false, t, egui::Id::new("comp-preset-delete")) + .on_hover_text(tr("Delete Preset")); + auto.add("dialog.comp.presetDelete", del_r, "Delete Preset"); + if del.clicked() { + cmds.push(("comp.deletePreset", json!({"name": q.name}))); } } }); - auto.add("dialog.comp.preset", r.response.rect, "Preset"); ui.end_row(); ui.label(tr("Width:")); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 81fa6bab9..26e94fe1d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -65,7 +65,8 @@ pub struct DialogState { pub fn open_new_comp(app: &mut EffectcraftApp) { let n = app.session.project.comps().count() + 1; let last = app.dialog_state.last_new_comp.clone().unwrap_or_default(); - app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, ..last }; + app.dialog_state.comp = + CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; app.dialog = Some(Dialog::NewComp); } @@ -311,10 +312,24 @@ fn about_main(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, cmd: &mut fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, existing: bool) { let mut d = app.dialog_state.comp.clone(); let mut result = None; + let custom = app.session.prefs.comp_presets.clone(); + let mut cmds = Vec::new(); modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto); + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); }); - if result.is_none() && ui_enter(ctx) { + if d.preset_name.is_some() { + // Save Preset asks for the name in its own window over the dialog, as After Effects does. + result = None; + modal(ctx, "Choose Name", vec2(360.0, 150.0), t, |ui| super::comp_settings::choose_name(ui, &mut d, t, &mut app.auto, &custom, &mut cmds)); + } + // Enter that saved a preset name (or while one is typed) doesn't also press OK. + let preset_busy = !cmds.is_empty() || d.preset_name.is_some(); + for (id, params) in cmds { + if let Err(e) = app.session.execute(id, params) { + app.ui.status = e.to_string(); + } + } + if result.is_none() && !preset_busy && ui_enter(ctx) { result = Some(super::comp_settings::params(&d)); } app.dialog_state.comp = d; diff --git a/crates/ui-egui/tests/ui_comp_presets.rs b/crates/ui-egui/tests/ui_comp_presets.rs new file mode 100644 index 000000000..0b0b30720 --- /dev/null +++ b/crates/ui-egui/tests/ui_comp_presets.rs @@ -0,0 +1,85 @@ +//! Composition Settings: saving, picking and deleting your own presets (egui_kittest). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Edit", "width": 2560, "height": 1440, "frameRate": 60, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + // The automation registry still holds the previous frame's elements: one more frame. + 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(3); +} + +fn open_settings(h: &mut Harness<'_, EffectcraftApp>) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "comp.settings", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); +} + +fn preset_names(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.prefs.comp_presets.iter().map(|q| q.name.clone()).collect() +} + +/// Save the comp's size and rate as a preset (Enter saves without pressing OK), then delete it. +#[test] +fn composition_settings_save_and_delete_your_own_preset() { + let mut h = harness(); + open_settings(&mut h); + assert!(h.state().auto.find("dialog.comp.presetDelete").is_none(), "nothing of yours to delete yet"); + click(&mut h, "dialog.comp.presetSave"); + // Choose Name opens with the keyboard in its field. + h.input_mut().events.push(Event::Text("1440p 60".into())); + h.run_steps(2); + key(&mut h, Key::Enter); + assert_eq!(preset_names(&h), ["1440p 60"]); + let q = &h.state().session.prefs.comp_presets[0]; + assert_eq!((q.width, q.height, q.pixel_aspect, q.frame_rate), (2560, 1440, 1.0, 60.0)); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings), "Enter in the name field doesn't press OK"); + assert!(h.state().auto.find("dialog.comp.presetName").is_none(), "the name row closes"); + // The comp's values are now that preset, so it can be deleted. + click(&mut h, "dialog.comp.presetDelete"); + assert!(preset_names(&h).is_empty()); + assert!(h.state().auto.find("dialog.comp.presetDelete").is_none()); +} + +/// A built-in preset's name can't be taken; Cancel closes the name row without saving. +#[test] +fn a_preset_name_cannot_be_a_built_in_one() { + let mut h = harness(); + open_settings(&mut h); + click(&mut h, "dialog.comp.presetSave"); + click(&mut h, "dialog.comp.presetName"); + h.input_mut().events.push(Event::Text("HDTV 1080 24".into())); + h.run_steps(2); + click(&mut h, "dialog.comp.presetNameSave"); + assert!(preset_names(&h).is_empty(), "the built-in name is refused"); + click(&mut h, "dialog.comp.presetNameCancel"); + assert!(h.state().auto.find("dialog.comp.presetName").is_none()); + assert!(preset_names(&h).is_empty()); +}