mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
67434e9038
commit
14b6b9c242
6 files changed
+380
-18
No files matched your search
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string()))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
@@ -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<Vec<CompPreset>, D::Error> {
|
||||
let v = Vec::<Value>::deserialize(d)?;
|
||||
Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect())
|
||||
}
|
||||
|
||||
fn default_labels() -> Vec<LabelPref> {
|
||||
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<CompPreset>,
|
||||
/// 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(());
|
||||
|
||||
@@ -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::<Vec<_>>(), ["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::<Vec<_>>(), ["Edit 1440p 60"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_levels_are_honoured() {
|
||||
let mut s = session_with_comp();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! (`dialog.comp.<field>`).
|
||||
|
||||
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<String>,
|
||||
pub dur_tc: Option<String>,
|
||||
/// The name being typed for Save Preset (the name row shows while `Some`).
|
||||
pub preset_name: Option<String>,
|
||||
}
|
||||
|
||||
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<String>, 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<serde_json::Value> {
|
||||
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::<bool>(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<serde_json::Value> {
|
||||
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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
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());
|
||||
}
|
||||
Reference in new issue
Block a user